The quickest way to learn SQL is to write queries against a real database on your first day, not after three chapters of theory. You need the sqlite3 shell, which installs in one command and runs without a server, and a sample database big enough to make your mistakes visible. Watching a JOIN return 213 Iron Maiden tracks when you expected 21 albums teaches you more than a chapter on JOINs.
This guide covers how to learn SQL step by step: the order to learn it in, the exact practice setup, real queries with their output, and the three dialect traps that bite people when they move from SQLite to PostgreSQL or MySQL. It closes with the eight SQL books worth buying at each stage, with current editions and stock checked on Amazon. Every query below was run on Ubuntu 26.04 with SQLite 3.46, PostgreSQL 18 and MySQL 8.4, plus a SQLite check on Rocky Linux 10, in October 2026.
Best SQL books for each stage
You don’t need eight books. You need one book for the stage you are at, plus the practice database below. Pick by what you are starting from:
- Best first book for a complete beginner: Head First SQL, 2nd Edition (June 2026, the first new edition since 2007)
- Best value: Practical SQL, 2nd Edition, PostgreSQL with real public datasets
- Best short core text: Learning SQL, 3rd Edition
- Best for 15-minute daily sessions: Learn SQL in a Month of Lunches
- Best for analysts: SQL for Data Analysis
- Best second book for developers: SQL Antipatterns, Volume 1
The two picks not on that list, SQL Cookbook and T-SQL Fundamentals, are for later: the cookbook once you can write a JOIN without looking it up, and Ben-Gan’s book only if your job runs on Microsoft SQL Server.
How we picked the books
Every pick was pulled from Amazon’s catalogue API on the day of writing, so the edition, page count, publication date and stock state in this guide are what Amazon itself reports, not what a 2019 list remembered. We kept only paperbacks sold by Amazon, matched each cover to the exact edition linked, and checked what each book covers against the publisher or retailer description. The older version of this page recommended Head First SQL’s 2007 edition and a 134-page 2016 primer; the first now has a 2026 rewrite and the second has aged out.
A few well-known titles didn’t make it. When we checked, Effective SQL was listed at about $94 with a shipping lead time and Database Design for Mere Mortals was out of stock. Sams Teach Yourself SQL in 10 Minutes a Day is still on its 2019 edition and also showed a shipping delay. Those are fine books if you find them used; they just aren’t what we would send a beginner to buy today.
| Book | Edition (year) | Pages | Stage | Price band when checked |
|---|---|---|---|---|
| Head First SQL | 2nd (2026) | 738 | First book | $55 to $60 |
| Practical SQL | 2nd (2022) | 464 | First book, analysis | About $20 |
| Learning SQL | 3rd (2020) | 377 | First or second book | $30 to $35 |
| Learn SQL in a Month of Lunches | 1st (2025) | 304 | First book | Under $40 |
| SQL Cookbook | 2nd (2020) | 567 | Practice, reference | About $42 |
| SQL for Data Analysis | 1st (2021) | 357 | Analyst track | $35 to $40 |
| SQL Antipatterns, Vol. 1 | 1st (2022) | 380 | Developer track | $45 to $50 |
| T-SQL Fundamentals | 4th (2023) | 608 | SQL Server track | About $50 |
Learn SQL in this order
Most people stall because they learn SQL in the order a reference manual lists it, starting with data types and CREATE TABLE. Reading data is what you’ll do most days, so start there and leave schema design until you have felt why it matters. This is the order the rest of this guide follows, and it’s the order we’d use with a new hire:
- SELECT, WHERE, ORDER BY and LIMIT on one table
- Aggregates: COUNT, SUM, GROUP BY and HAVING
- JOINs across two or more tables, then LEFT JOIN
- Subqueries, CTEs and window functions
- INSERT, UPDATE and DELETE inside a transaction
- CREATE TABLE, constraints and indexes, then reading a query plan
- Moving the same skills to PostgreSQL, MySQL or SQL Server
Steps 1 to 3 cover a large share of everyday reporting queries, and you can get through them in a couple of weeks of evening practice. Window functions in step 4 are where SQL stops feeling like a filter and starts feeling like a language, so do not skip them because they look advanced.
Install a practice database
SQLite is the right first engine because there’s no server, no user accounts and no port to open: a database is a single file and the shell reads it directly. The skills carry over to every other engine with small syntax changes, which the last section covers. On Ubuntu 26.04, Ubuntu 24.04 and Debian 13 the package is sqlite3:
sudo apt install -y sqlite3
Rocky Linux, AlmaLinux and RHEL name the package sqlite, and it ships the same sqlite3 command. It works with SELinux left in enforcing mode, since the shell only touches files in your home directory:
sudo dnf install -y sqlite
macOS already has /usr/bin/sqlite3, so Mac users can skip the install. Confirm the shell runs:
sqlite3 --version
Both lab machines, Ubuntu 26.04 and Rocky Linux 10, printed the same release:
3.46.1 2024-08-13 09:16:08 c9c2ab54ba1f5f46360f1b4f35d849cd3f080e6fc2b6c60e91b16c63f69aalt1 (64-bit)
For practice data, use Chinook, a sample music store with artists, albums, tracks, customers and invoices. It is small enough to download in a second and big enough (3,503 tracks, 412 invoices) that a bad query gives you a visibly wrong answer. The latest download path always fetches the newest release:
mkdir -p ~/sql-practice && cd ~/sql-practice
curl -fsSLO https://github.com/lerocha/chinook-database/releases/latest/download/Chinook_Sqlite.sqlite
sqlite3 Chinook_Sqlite.sqlite ".tables"
The 1.1 MB file holds eleven tables. Track, Album and Artist are where you’ll practise JOINs; Invoice and InvoiceLine are where aggregates get interesting:
Album Employee InvoiceLine PlaylistTrack
Artist Genre MediaType Track
Customer Invoice Playlist
Save each query in a .sql file and run it with sqlite3 -table Chinook_Sqlite.sqlite < query.sql. The -table flag draws the bordered tables you see below, and keeping queries in files means you can edit and rerun them instead of retyping. If you prefer a GUI, DBeaver opens the same file.
Write your first SQL queries
Start with one table and four clauses. This query finds the five longest tracks with Mick Jagger in the composer credits, converting milliseconds to seconds on the way:
SELECT Name, Composer, Milliseconds / 1000 AS seconds
FROM Track
WHERE Composer LIKE '%Jagger%'
ORDER BY seconds DESC
LIMIT 5;
Notice that ORDER BY can use the seconds alias, and that integer division dropped the fraction, so the 479.2-second opener prints as 479:
+-----------------+---------------------------+---------+
| Name | Composer | seconds |
+-----------------+---------------------------+---------+
| Out Of Control | Jagger/Richards | 479 |
| Gimmie Shelters | Jagger/Richards | 382 |
| Sister Morphine | Faithfull/Jagger/Richards | 376 |
| Thru And Thru | Jagger/Richards | 375 |
| Memory Motel | Jagger/Richards | 365 |
+-----------------+---------------------------+---------+
Aggregates come next. GROUP BY collapses rows into one row per group, and HAVING filters those groups the way WHERE filters rows. This one totals revenue per billing country and keeps countries with at least 20 invoices:
SELECT BillingCountry, COUNT(*) AS invoices, ROUND(SUM(Total), 2) AS revenue
FROM Invoice
GROUP BY BillingCountry
HAVING COUNT(*) >= 20
ORDER BY revenue DESC;
Six countries pass the filter, and the USA alone brings in more than Canada and France combined:
+----------------+----------+---------+
| BillingCountry | invoices | revenue |
+----------------+----------+---------+
| USA | 91 | 523.06 |
| Canada | 56 | 303.96 |
| France | 35 | 195.1 |
| Brazil | 35 | 190.1 |
| Germany | 28 | 156.48 |
| United Kingdom | 21 | 112.86 |
+----------------+----------+---------+
Change HAVING to WHERE and run it again. SQLite stops with misuse of aggregate: COUNT(), because WHERE filters rows before any grouping happens and there’s nothing to count yet. That one error is the fastest way to remember which clause runs before grouping and which runs after.
Join tables
Real data is spread across tables, and JOINs are how you put it back together. Track points at Album, and Album points at Artist, so counting tracks per artist needs two joins:
SELECT ar.Name AS artist, COUNT(t.TrackId) AS tracks
FROM Artist ar
JOIN Album al ON al.ArtistId = ar.ArtistId
JOIN Track t ON t.AlbumId = al.AlbumId
GROUP BY ar.Name
ORDER BY tracks DESC
LIMIT 5;
Iron Maiden leads with 213 tracks. The fifth row is the TV series Lost, which tells you the dataset treats soundtrack and episode sources as artists too, and that is the kind of thing you only learn by looking at results:
+--------------+--------+
| artist | tracks |
+--------------+--------+
| Iron Maiden | 213 |
| U2 | 135 |
| Led Zeppelin | 114 |
| Metallica | 112 |
| Lost | 92 |
+--------------+--------+
An inner JOIN silently drops artists that have no albums. A LEFT JOIN keeps every row from the left table and fills the missing side with NULL, so checking for that NULL finds the orphans:
SELECT COUNT(*) AS artists_without_albums
FROM Artist ar
LEFT JOIN Album al ON al.ArtistId = ar.ArtistId
WHERE al.AlbumId IS NULL;
71 of the artists have no album at all. The previous query never showed them, and it never warned you either:
+------------------------+
| artists_without_albums |
+------------------------+
| 71 |
+------------------------+
That’s the lesson to carry into every report you write: decide whether missing rows should count, then pick the join that matches.
Use CTEs and window functions
A CTE (the WITH block) names an intermediate result so the final query stays readable, and a window function computes across related rows without collapsing them the way GROUP BY does. Together they answer questions like “who are the top customers in each country” in one statement:
WITH customer_spend AS (
SELECT c.Country, c.FirstName || ' ' || c.LastName AS customer, SUM(i.Total) AS spent
FROM Customer c
JOIN Invoice i ON i.CustomerId = c.CustomerId
GROUP BY c.CustomerId
)
SELECT Country, customer, ROUND(spent, 2) AS spent,
RANK() OVER (PARTITION BY Country ORDER BY spent DESC) AS rank_in_country
FROM customer_spend
WHERE Country IN ('Brazil', 'Canada')
ORDER BY Country, rank_in_country;
Look at how RANK handles ties: four Brazilian customers spent exactly 37.62, so all four share rank 2, and in Canada six customers tie at rank 3. RANK leaves gaps after a tie, so a ninth Canadian customer would be ranked 9, not 4:
+---------+-------------------+-------+-----------------+
| Country | customer | spent | rank_in_country |
+---------+-------------------+-------+-----------------+
| Brazil | Luís Gonçalves | 39.62 | 1 |
| Brazil | Eduardo Martins | 37.62 | 2 |
| Brazil | Alexandre Rocha | 37.62 | 2 |
| Brazil | Roberto Almeida | 37.62 | 2 |
| Brazil | Fernanda Ramos | 37.62 | 2 |
| Canada | François Tremblay | 39.62 | 1 |
| Canada | Jennifer Peterson | 38.62 | 2 |
| Canada | Mark Philips | 37.62 | 3 |
| Canada | Robert Brown | 37.62 | 3 |
| Canada | Edward Francis | 37.62 | 3 |
| Canada | Martha Silk | 37.62 | 3 |
| Canada | Aaron Mitchell | 37.62 | 3 |
| Canada | Ellie Sullivan | 37.62 | 3 |
+---------+-------------------+-------+-----------------+
Swap RANK() for DENSE_RANK() and ROW_NUMBER() and compare the last column. Knowing which one a report needs is a common interview question, and three runs of the same query settle it better than any definition.
Change data inside a transaction
UPDATE and DELETE are where beginners lose data, because a missing or wrong WHERE clause changes every matching row at once. Practise them inside a transaction so you can look before you commit. This doubles the price of every rock track, counts the damage, then rolls it back:
BEGIN;
UPDATE Track SET UnitPrice = UnitPrice * 2 WHERE GenreId = 1;
SELECT changes() AS rows_changed;
ROLLBACK;
SELECT MAX(UnitPrice) AS max_rock_price FROM Track WHERE GenreId = 1;
The UPDATE touched 1,297 rows, and after the ROLLBACK the highest rock price is back to 0.99, so nothing stuck:
+--------------+
| rows_changed |
+--------------+
| 1297 |
+--------------+
+----------------+
| max_rock_price |
+----------------+
| 0.99 |
+----------------+
changes() is SQLite’s own function. PostgreSQL prints the count on its own (UPDATE 1297 for the same statement on the PostgreSQL copy) and MySQL reports it as “rows affected”, but the habit is the same everywhere: open a transaction, check the count, then decide.
Read the query plan
Once your queries return the right rows, the next question is how the engine finds them. EXPLAIN QUERY PLAN shows that, and comparing the plan before and after an index is the clearest demonstration of what an index does:
EXPLAIN QUERY PLAN SELECT * FROM Track WHERE Composer = 'U2';
CREATE INDEX idx_track_composer ON Track (Composer);
EXPLAIN QUERY PLAN SELECT * FROM Track WHERE Composer = 'U2';
Without the index SQLite reads every row in Track (SCAN). With it, the engine jumps straight to the matching entries (SEARCH ... USING INDEX):
QUERY PLAN
`--SCAN Track
QUERY PLAN
`--SEARCH Track USING INDEX idx_track_composer (Composer=?)
Rerunning the file fails at CREATE INDEX because the index already exists, so run DROP INDEX idx_track_composer; first. On 3,503 rows you will not feel the difference. On a production table with tens of millions of rows, that one line is the difference between a query that returns at once and one that times out, which is why PostgreSQL’s EXPLAIN ANALYZE is worth learning early.
Move from SQLite to PostgreSQL or MySQL
Chinook ships a load script for each engine, so you can rerun everything above on a real server. On Ubuntu 26.04 the plain postgresql and mysql-server packages install current releases. Rocky Linux 10’s AppStream installs an older PostgreSQL from postgresql-server by default, but it also ships postgresql18-server and mysql8.4-server, so neither distro needs an outside repository. The PostgreSQL install guide and the MySQL setup on Ubuntu and Debian cover the server side; on Rocky, follow the Rocky and AlmaLinux PostgreSQL steps. With PostgreSQL running, give your login a role that can create databases and load the script:
curl -fsSLO https://github.com/lerocha/chinook-database/releases/latest/download/Chinook_PostgreSql.sql
sudo -u postgres createuser --createdb $USER
psql -d postgres -q -f Chinook_PostgreSql.sql
The PostgreSQL version of Chinook uses lowercase snake_case names (invoice, billing_country), so queries need small edits. The bigger differences are in behaviour, and these are the ones we hit running the same SQL on all three engines:
| Behaviour | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
| Selecting a column that isn’t grouped | Runs, returns an arbitrary value | ERROR, must appear in GROUP BY | ERROR 1055 (ONLY_FULL_GROUP_BY is on by default) |
first || ' ' || last | Joins the strings | Joins the strings | Logical OR, returns 0 with warnings |
| Join strings safely | || or concat() | || or concat() | CONCAT() |
| Auto-numbered key | INTEGER PRIMARY KEY | GENERATED ALWAYS AS IDENTITY | AUTO_INCREMENT |
| First N rows | LIMIT n | LIMIT n | LIMIT n |
SQL Server is the odd one out on that last row: it uses SELECT TOP (n), or OFFSET ... FETCH after an ORDER BY, which is one reason the T-SQL book in the picks below exists. The three errors that follow are common ones when you switch engines.
ERROR: column “invoice.billing_city” must appear in the GROUP BY clause or be used in an aggregate function
This is PostgreSQL refusing a query that SQLite happily ran. We grouped invoices by country but also selected the city:
SELECT billing_country, billing_city, COUNT(*) AS invoices
FROM invoice
GROUP BY billing_country
ORDER BY invoices DESC
LIMIT 3;
PostgreSQL stops with a pointer to the offending column:
ERROR: column "invoice.billing_city" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT billing_country, billing_city, COUNT(*) AS invoices F...
^
SQLite returned “Boston” for the USA row instead, as if all 91 US invoices came from one city. They come from 12 different cities, so SQLite’s answer was simply wrong and nothing said so. That’s the strongest argument for moving to PostgreSQL once the basics stick: it refuses ambiguous queries instead of guessing. The fix is to aggregate the column or group by it:
SELECT billing_country, COUNT(DISTINCT billing_city) AS cities, COUNT(*) AS invoices
FROM invoice
GROUP BY billing_country
ORDER BY invoices DESC
LIMIT 3;
Now each column means something. The third row reads Brazil here while SQLite put France third, because both have 35 invoices and neither query breaks the tie. Add a second ORDER BY column whenever ties are possible:
billing_country | cities | invoices
-----------------+--------+----------
USA | 12 | 91
Canada | 8 | 56
Brazil | 4 | 35
(3 rows)
Four Brazilian cities against twelve American ones is the kind of detail the SQLite version hid behind a single city name.
ERROR 1055 (42000): this is incompatible with sql_mode=only_full_group_by
MySQL rejects the same loose GROUP BY. Chinook’s MySQL script keeps the original CamelCase names, so load it and run the query with the client’s table output (-t):
curl -fsSLO https://github.com/lerocha/chinook-database/releases/latest/download/Chinook_MySql.sql
sudo mysql < Chinook_MySql.sql
sudo mysql -t Chinook -e "SELECT BillingCountry, BillingCity, COUNT(*) AS invoices FROM Invoice GROUP BY BillingCountry ORDER BY invoices DESC LIMIT 3;"
The server refuses it, because ONLY_FULL_GROUP_BY is the first entry in the sql_mode it reported on a fresh install:
ERROR 1055 (42000) at line 1: Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'Chinook.Invoice.BillingCity' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
Old tutorials tell you to remove the mode from sql_mode. Do not. Fix the query exactly as in the PostgreSQL case, because turning the check off only brings back the arbitrary-city answer SQLite gave.
MySQL returns 0 for FirstName || ‘ ‘ || LastName
This one doesn’t error, which makes it worse. In MySQL, || is a logical OR unless the server runs with PIPES_AS_CONCAT, so the string-joining habit you built in SQLite and PostgreSQL turns every name into 0. Run the query with SHOW WARNINGS straight after it to see why:
sudo mysql -t Chinook -e "SELECT FirstName || \" \" || LastName AS customer FROM Customer LIMIT 1; SHOW WARNINGS;"
The name comes back as 0, and the warnings explain that MySQL treated || as OR and tried to read each name as a number:
+----------+
| customer |
+----------+
| 0 |
+----------+
+---------+------+-------------------------------------------------------------------------------------------------------+
| Level | Code | Message |
+---------+------+-------------------------------------------------------------------------------------------------------+
| Warning | 1287 | '|| as a synonym for OR' is deprecated and will be removed in a future release. Please use OR instead |
| Warning | 1287 | '|| as a synonym for OR' is deprecated and will be removed in a future release. Please use OR instead |
| Warning | 1292 | Truncated incorrect DOUBLE value: 'Luís' |
| Warning | 1292 | Truncated incorrect DOUBLE value: 'Gonçalves' |
+---------+------+-------------------------------------------------------------------------------------------------------+
Use CONCAT(FirstName, ' ', LastName) on MySQL and it returns “Luís Gonçalves” as expected. If you write SQL that has to run on more than one engine, concat() also works in PostgreSQL and in SQLite since 3.44.0, so it is the safer habit. Watch NULLs, though: MySQL’s CONCAT() returns NULL if any argument is NULL, while PostgreSQL and SQLite concat() skip NULL arguments (in SQLite, concat('a', NULL, 'b') returns ab where 'a' || NULL || 'b' returns NULL). Once you’re comfortable reading other people’s queries, the SQL injection walkthrough shows why building queries by joining strings in application code is the one habit you should never form.
1. Head First SQL, 2nd Edition
Kimberly Fessel’s June 2026 edition replaces the 2007 one that many “learn SQL” lists still link. It keeps the visually rich, conversational Head First format and moves from SQL fundamentals to intermediate topics such as subqueries and joins. Its exercises run on MySQL, with an appendix on installing it, and chapter 18, Life Beyond MySQL, looks at the other engines.
Buy it if you are a complete beginner or gave up on a text-heavy book before. Pass on it if you learn best from compact prose: at 738 pages it is the longest book here, and the visual format spends pages on repetition on purpose. It was $55 to $60 when we checked. Check the current price on Amazon.
2. Practical SQL, 2nd Edition
Anthony DeBarros is a data journalist, and the book reads like one: its chapters lean on real public datasets (US Census figures, New York City taxi rides, USGS earthquake records) in PostgreSQL with pgAdmin. The second edition added a setup chapter and one on JSON, later chapters cover PostGIS spatial queries and running PostgreSQL from the command line, and the book closes on database maintenance and telling your data’s story. For a first book that also teaches you a production-grade engine, nothing here beats it on price.
Best fit: beginners heading toward analysis or reporting, and anyone who wants PostgreSQL from day one. Look elsewhere if your employer runs SQL Server, because the publisher says outright that T-SQL isn’t covered. Amazon had it at about $20, half its $39.99 list price, when we checked. See the current price on Amazon.
3. Learning SQL, 3rd Edition
At 377 pages, Alan Beaulieu’s book is one of the shorter general SQL texts here, and less than half the length of Head First SQL. The examples run on MySQL against the Sakila sample database (a DVD rental store, much like Chinook), and the third edition added chapters on analytic functions, very large databases and SQL with big data. The chapters come with exercises, which is what turns reading into skill.
Good for developers and sysadmins who already code and want SQL explained once, clearly. The trade-off: it is from 2020 and leans on MySQL syntax, so PostgreSQL users translate as they go. It was $30 to $35, about half list, when we checked. Check Learning SQL on Amazon.
4. Learn SQL in a Month of Lunches
Jeff Iannucci splits SQL into 24 short lessons, each with a hands-on lab Manning sizes at about 15 minutes. It starts by setting up MySQL and works through filtering, joins, functions and aggregations, then stored procedures, views and reading SQL that someone else wrote. That last part matters more than beginners expect, because most of your early SQL work will be fixing queries you inherited.
Made for people learning around a full-time job, including Excel and Power BI users who already see SQL in their tools. Its weak spot: the publisher’s topic list covers set operators, subqueries, variables, cursors and transactions but doesn’t mention window functions, so plan a second book for step 4 of the path. Under $40 when we checked, with Amazon showing limited stock. Check stock on Amazon.
5. SQL Cookbook, 2nd Edition
Molinaro and de Graaf’s cookbook is a problem-and-solution reference, with each recipe written for Oracle, DB2, SQL Server, MySQL and PostgreSQL side by side. The second edition was revised around window functions and added recipes built on CTEs, which is exactly where the path above leaves you. It’s the book you keep open when you know what result you want but not how to write it.
Reach for it once you are past the basics and work in more than one database, since the per-engine solutions double as a dialect phrasebook. Not a first book: it assumes you can already write joins and won’t teach them from zero. About $42 when we checked. See SQL Cookbook on Amazon.
6. SQL for Data Analysis
Cathy Tanimura’s book picks up where beginner books stop and asks what analysts actually do with SQL: preparing messy data, time series, cohort analysis, text analysis, outlier detection and A/B test results. Window functions, subqueries and regular expressions show up as tools for those jobs rather than as syntax lessons, which is how you will use them at work.
Who should buy it: analysts and data scientists who know the basics and want to stop exporting everything to a spreadsheet. Wrong book if you build applications. It’s written for analysis work, and a developer gets more from pick 7. It was $35 to $40 with limited stock when we checked. Check the price on Amazon.
7. SQL Antipatterns, Volume 1
Bill Karwin catalogues the designs and queries that look right and then hurt for years: comma-separated lists stored in one column (his “Jaywalking” chapter), careless NULL handling, foreign key mistakes, spaghetti queries and SQL injection. The 2022 edition updated the examples to MySQL 8.0 and Python, and added a set of short mini-antipatterns. It is the book that turns “my query works” into “my schema will still work in two years”.
Buy it when you are a developer who writes the tables your application uses. Hold off if you haven’t designed a schema yet, since the antipatterns only land once you’ve made a few of them. It was $45 to $50 with limited stock when we checked. See SQL Antipatterns on Amazon.
8. T-SQL Fundamentals, 4th Edition
If your workplace runs Microsoft SQL Server or Azure SQL, learn T-SQL from Itzik Ben-Gan. The fourth edition covers SQL Server 2022, Azure SQL Database and Azure SQL Managed Instance. It starts with logical query processing, the order in which clauses actually run, and that explains why WHERE can’t see an aggregate. Later chapters cover temporal tables, transactions and concurrency, and SQL Graph.
Right for anyone whose job runs on SQL Server, from report writers to .NET developers. Skip for now if you are on PostgreSQL or MySQL, because the dialect-specific parts won’t transfer. About $50 when we checked. Check T-SQL Fundamentals on Amazon.
Follow a four-week SQL study plan
A plan beats motivation, so this is the one we would hand someone starting from zero with an hour a day. It uses the Chinook file from above and one book from the picks; swap the weeks around if your job needs a stage sooner:
- Week 1: install
sqlite3, load Chinook, and write ten SELECT queries with WHERE, ORDER BY and LIMIT, then five with GROUP BY and HAVING - Week 2: joins only, including a LEFT JOIN that finds orphans in every pair of related tables
- Week 3: rewrite three of your week 2 queries as CTEs, then rank something with RANK, DENSE_RANK and ROW_NUMBER
- Week 4: load Chinook into PostgreSQL, rerun everything, fix what breaks, and practise UPDATE and DELETE inside BEGIN and ROLLBACK
Week 4 is the one that sticks, because the errors teach you what SQLite let you get away with. After that, pick the track that fits your work. Read the PostgreSQL books for a deeper look at that engine, work through the MySQL and MariaDB reading list if you run those, or pick a language from our programming book picks so you can query a database from your own code. Python is a good choice there, since its standard library already includes the sqlite3 module you would use against this exact file.







