Skip to content

SQL lesson 1 of 8

SQL Tables, Rows and Your First SELECT

Learn what a SQL table really is, how to create one and put rows in it, and how SELECT asks a database for exactly the columns you want — every example runnable in a real SQLite database in your browser.

Published · Every example on this page was run before it was published.

Most of the code you have written so far probably kept its data in variables: a list, an array, an object. Close the program and the data is gone. A database is the opposite deal — the data outlives the program, and the program asks for the slice of it that it needs right now. SQL is the language that asking is written in, and SELECT is the one word you will type more than any other.

This whole lesson runs in your browser. The playground here is real SQLite, compiled to WebAssembly, and it gives every run a brand-new, empty database. That single fact shapes every example on this page: there are no tables waiting for you, so each example creates its own table, puts a few rows in it, and then queries them — all in one script. Copy any block below into the SQL playground and press Run, and you will get exactly the output shown underneath it.

What a table actually is

A table is a grid with named columns and any number of rows.

  • A column has a name and holds one kind of fact — a name, a price, a date. Every row has a slot for every column.
  • A row is one thing the table is about: one tool, one member, one payment. Rows have no order of their own; if you want them in an order, you ask for one (that is the next lesson but one).

The tables in this lesson belong to an invented community tool library, where neighbours borrow a drill instead of buying one. Its tools table has four columns — an id, a name, the shelf the tool lives on, and the refundable deposit in pence, stored as a whole number.

Here is that table being built and then read back:

SQL
CREATE TABLE tools (
  id      INTEGER PRIMARY KEY,
  name    TEXT,
  shelf   TEXT,
  deposit INTEGER
);

INSERT INTO tools (name, shelf, deposit) VALUES
  ('hedge trimmer',  'garden', 950),
  ('cordless drill', 'power',  1200),
  ('wheelbarrow',    'garden', 500),
  ('tile cutter',    'power',  1500),
  ('hand saw',       'hand',   350);

SELECT * FROM tools;
Output
id | name           | shelf  | deposit
---+----------------+--------+--------
 1 | hedge trimmer  | garden |     950
 2 | cordless drill | power  |    1200
 3 | wheelbarrow    | garden |     500
 4 | tile cutter    | power  |    1500
 5 | hand saw       | hand   |     350
(5 rows)

Three statements ran, in the order they were written, each ended by a semicolon. Only the third one returned any rows, so only the third one printed a table. The line under the result — (5 rows) — is the playground telling you how many rows came back; it is not part of the data.

Notice that id was never mentioned in the INSERT, yet every row has one. INTEGER PRIMARY KEY in SQLite means "this column identifies the row", and when you leave it out SQLite fills in the next whole number for you. Lesson 7 comes back to what a primary key promises.

SELECT: choosing columns

SELECT * FROM tools means "every column, every row". The * is convenient while you are exploring and a poor habit afterwards, because it hands you columns you did not ask for and quietly changes what your query returns the day someone adds a column. Naming the columns is better, and it also lets you decide their order — the order in the query wins, not the order in the table:

SQL
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
  ('hedge trimmer', 'garden', 950),
  ('cordless drill', 'power', 1200),
  ('wheelbarrow', 'garden', 500);

SELECT shelf, name FROM tools;
Output
shelf  | name
-------+---------------
garden | hedge trimmer
power  | cordless drill
garden | wheelbarrow
(3 rows)

From here on the examples use that shorter one-line CREATE TABLE, so the setup does not crowd out the query being explained. Both spellings mean the same thing.

Read a SELECT out of order and it makes more sense than reading it left to right. FROM tools says where the rows come from; shelf, name says which parts of each row you want back. The database works in that order too, whatever the words look like.

Columns you compute

A SELECT list is not limited to columns that exist. Anything that works out to a value can go there — a sum, a piece of text stuck onto another, a function call — and the result is a column like any other.

SQL
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
  ('hedge trimmer', 'garden', 950),
  ('cordless drill', 'power', 1200),
  ('hand saw', 'hand', 350);

SELECT name, deposit, deposit / 100.0 AS deposit_pounds, upper(shelf) AS shelf_label
FROM tools;
Output
name           | deposit | deposit_pounds | shelf_label
---------------+---------+----------------+------------
hedge trimmer  |     950 |            9.5 | GARDEN
cordless drill |    1200 |             12 | POWER
hand saw       |     350 |            3.5 | HAND
(3 rows)

AS gives a computed column a name. Without it the column heading is whatever the database decides to call the expression, which is rarely something you would choose. The name after AS is for the output only — it is not a new column in the table, and nothing was written back. (The drill's 12 is a decimal number that happens to have nothing after the point, and the playground prints it without one.)

Why deposit / 100.0 rather than deposit / 100? Because both sides of / are whole numbers in the second version, and SQLite then does whole-number division and throws the remainder away:

SQL
SELECT 950 / 100 AS whole_number_division, 950 / 100.0 AS with_a_decimal, 7 / 2 AS seven_halved;
Output
whole_number_division | with_a_decimal | seven_halved
----------------------+----------------+-------------
                    9 |            9.5 |            3
(1 row)

That is worth having burnt in early, because it bites in real reports. PostgreSQL behaves the same way with two integers; MySQL is the odd one out and gives you 3.5 for 7 / 2. Writing one side as a decimal — or wrapping it in CAST(... AS REAL) — makes your intent explicit in every dialect.

This example also shows that FROM is optional in SQLite when you are not reading a table. SELECT 2 + 2; is a complete, legal query, which makes the playground a usable calculator when you just want to check what an expression does.

Text, numbers and NULL

Values in a table come in a few flavours. Text goes in single quotes; numbers go bare. Get that wrong and SQLite will surprise you rather than stop you, which is the subject of the mistakes section below.

The third flavour is NULL, which means "no value here". It is not zero and it is not an empty string — it is the absence of an answer, and it behaves unlike anything else in SQL:

SQL
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
  ('hedge trimmer', 'garden', 950),
  ('donated ladder', 'garden', NULL);

SELECT name, deposit, deposit + 100 AS plus_a_pound, deposit IS NULL AS unknown_deposit
FROM tools;
Output
name           | deposit | plus_a_pound | unknown_deposit
---------------+---------+--------------+----------------
hedge trimmer  |     950 |         1050 |               0
donated ladder | NULL    | NULL         |               1
(2 rows)

The ladder was donated and nobody has set a deposit yet. Adding 100 to an unknown number gives another unknown number, so plus_a_pound is NULL as well — that is how NULL spreads through arithmetic. deposit IS NULL is the right way to test for it, and SQLite prints the true/false answer as 1 or 0 because it has no separate boolean type. The next lesson covers NULL in conditions properly; it is the single most common source of rows mysteriously going missing.

Comments, and what happens with no SELECT at all

Two dashes start a comment that runs to the end of the line, and /* ... */ comments out a block. A script with no query in it at all is not an error here — it just has nothing to print:

SQL
-- Build the table, then change our minds about querying it.
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO tools (name) VALUES ('hand saw');
/* SELECT * FROM tools; */
Output
Done. The statements ran, but none of them returned rows. Add a SELECT to see data.

That message is the playground's, not SQLite's. If you ever press Run and see it when you expected rows, the answer is almost always that the SELECT is commented out, or missing, or that you only wrote the CREATE and INSERT half of the script.

A worked example

Nothing new here — just the pieces above in one script, with the output read back carefully:

SQL
CREATE TABLE plots (
  id       INTEGER PRIMARY KEY,
  holder   TEXT,
  crop     TEXT,
  area_m2  INTEGER
);

INSERT INTO plots (holder, crop, area_m2) VALUES
  ('Asha',  'garlic',   12),
  ('Bruno', 'potatoes', 30),
  ('Cleo',  'herbs',    7),
  ('Devi',  NULL,       18);

SELECT holder, crop, area_m2, area_m2 * 3 AS beds_of_four, crop IS NULL AS not_planted_yet
FROM plots;
Output
holder | crop     | area_m2 | beds_of_four | not_planted_yet
-------+----------+---------+--------------+----------------
Asha   | garlic   |      12 |           36 |               0
Bruno  | potatoes |      30 |           90 |               0
Cleo   | herbs    |       7 |           21 |               0
Devi   | NULL     |      18 |           54 |               1
(4 rows)

Four rows in, four rows out — a plain SELECT with no conditions never adds or drops rows. area_m2 * 3 was computed per row, so each row got its own answer. Devi's plot has no crop recorded, so crop shows NULL and not_planted_yet shows 1; everyone else gets 0. And notice the alignment: the playground right-aligns numbers and left-aligns text, which makes a wrong type visible at a glance.

Common mistakes

Using double quotes for text

SQL
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO tools (name) VALUES ('hand saw');
SELECT name, "hand saw" AS double_quoted, 'hand saw' AS single_quoted FROM tools;
Output
name     | double_quoted | single_quoted
---------+---------------+--------------
hand saw | hand saw      | hand saw
(1 row)

In standard SQL, single quotes make text and double quotes name a column or table. "hand saw" should therefore be read as "the column called hand saw", and in PostgreSQL that query fails outright. SQLite is more forgiving than is good for you: when a double-quoted name matches no column, it silently treats it as text instead. The query runs, you learn nothing, and the same query breaks the day you point it at a real server. Use single quotes for values, always.

Forgetting the semicolon between statements

SQL
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT)
INSERT INTO tools (name) VALUES ('hand saw');
SELECT * FROM tools;
Output
Error in the statement on line 1: near "INSERT": syntax error

The semicolon is what ends a statement, so without one SQLite reads the first two lines as a single nonsensical statement and stops. The error names the line the failing statement starts on, which is line 1 here rather than line 2 where you would probably look first — worth knowing, because it is the same in every error you will meet in this playground.

Expecting a declared type to be enforced

SQL
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, deposit INTEGER);
INSERT INTO tools (name, deposit) VALUES ('hand saw', 'three hundred');
SELECT name, deposit, typeof(deposit) AS stored_as FROM tools;
Output
name     | deposit       | stored_as
---------+---------------+----------
hand saw | three hundred | text
(1 row)

deposit was declared INTEGER and a sentence went into it anyway. This is genuine SQLite behaviour, not a quirk of the playground: outside of a table created as STRICT, a column's declared type is a preference rather than a rule. PostgreSQL and MySQL both reject that insert. If you want SQLite to reject it too, add the word STRICT after the closing bracket of CREATE TABLE, and remember when you move to another database that it was checking all along and SQLite was not.

Next steps

You can now build a table, fill it, and ask for chosen and computed columns out of it. Every query so far has returned every row, which is almost never what a real question needs — the next lesson, filtering rows with WHERE, is about cutting the result down to the rows you actually mean, including the traps NULL sets for you. Before that, take any block above into the SQL playground, add a column to the SELECT list, and run it again.

Write it yourself

Reading about code and writing it are different skills. These exercises practise exactly what this lesson covered; they run in this tab and need no account.