Index Ai Academy
Phase 7 · Software engineering · Building software

Databases and SQL

Store, query and join data with the language every database speaks.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Almost every application keeps its data in a database: users, orders, conversations, the logs of every model call. SQL is the language nearly all of them speak, and it has lasted fifty years. AI work leans on it too. You'll query usage and cost data, store chat history, and keep vector embeddings in Postgres. Models are often asked to write SQL, so you need to read and check it.

What you'll be able to do

  • Design tables with primary and foreign keys
  • Write SQL to filter, sort, aggregate and join data
  • Pass values with placeholders so queries are safe from SQL injection
  • Explain what indexes and transactions do, and when you need them

What it covers

  1. Why not use files?
  2. Aggregates and GROUP BY
  3. Joins
  4. Revenue per customer
  5. Placeholders, always: SQL injection
  6. Indexes: fast lookups
  7. Three bugs that don't raise errors

Why not use files?

A CSV file is a notebook on a shared desk. It works until two people write in it at once, a crash leaves a page half-written, or you need "all orders over $100 from customers in Ohio" from ten million rows. A database is more like a well-run records office. It stores data in tables, answers questions written in SQL (Structured Query Language), finds rows fast with indexes, and keeps data consistent even when many users write at once.

Python ships with SQLite, a complete database stored in one file (or in memory). It's what this lesson uses. PostgreSQL is the usual choice for production servers. Its SQL is nearly identical for everything here.

Tables, rows and keys

A table is like a spreadsheet with strict columns. Each column has a name and a type, and each row is one record. Two kinds of key tie tables together. Think of a customer card and an order slip with the customer's number written on it:

  • A primary key uniquely identifies each row, like id. That's the number on the card.
  • A foreign key is a column holding another table's primary key. That's the number on the slip, and it's how an order points at its customer.
import sqlite3

db = sqlite3.connect(":memory:")       # a fresh database in memory
db.executescript("""
CREATE TABLE customers (
    id INTEGER PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    region TEXT
);
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    total REAL NOT NULL,
    status TEXT NOT NULL
);
INSERT INTO customers (email, region) VALUES
    ('ada@shop.test', 'west'), ('lin@shop.test', 'mountain'), ('sam@shop.test', 'west');
INSERT INTO orders (customer_id, total, status) VALUES
    (1, 249.0, 'paid'), (1, 25.0, 'paid'), (2, 89.5, 'refunded'), (2, 120.0, 'paid');
""")

for row in db.execute("SELECT id, email, region FROM customers WHERE region = 'west' ORDER BY email"):
    print(row)
It runs right here in your browser, with nothing to install.

SELECT picks columns, FROM names the table, WHERE filters rows and ORDER BY sorts them. SQL reads close to English, and you write what you want, not how to loop for it.

Aggregates and GROUP BY

A till receipt boils many items down to one total. Aggregate functions do the same to many rows: COUNT, SUM, AVG, MIN and MAX. GROUP BY is like sorting a stack of receipts into one pile per customer, then totaling each pile. It makes one result row per group. HAVING filters groups, the way WHERE filters rows.

Quick check
What does this print?
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE o (who, total)")
db.executemany("INSERT INTO o VALUES (?, ?)", [("a", 5), ("a", 7), ("b", 1)])
print(db.execute("SELECT SUM(total) FROM o WHERE who = 'a'").fetchone()[0])
  1. 12
  2. 5
  3. 2
  4. 13
Show the answer

12

WHERE keeps only the two rows for 'a', and SUM adds up their totals: 5 + 7 = 12.

Joins

Say you have a class roster and a list of lunch orders, and you want to line them up. A join combines rows from two tables where a condition matches, usually a foreign key equal to a primary key.

An inner join keeps only rows with a match on both sides: only the students who ordered lunch. A left join keeps every row from the left table, filling in NULL (SQL's None) where nothing matches: the whole roster, with blanks for students who didn't order.

In plain words: an inner join shows only the matches. A left join keeps everyone on the first list and leaves blanks where there's no match.

Try it now

SQL runs in this order, not the order you write it, which explains many confusing errors.

Worked example

Revenue per customer

One query joins the tables, keeps paid orders, groups by customer and adds up the totals. The left join keeps Sam, who hasn't bought anything.

import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE customers (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL, status TEXT);
INSERT INTO customers (email) VALUES ('ada@shop.test'), ('lin@shop.test'), ('sam@shop.test');
INSERT INTO orders (customer_id, total, status) VALUES
    (1, 249.0, 'paid'), (1, 25.0, 'paid'), (2, 89.5, 'refunded'), (2, 120.0, 'paid');
""")
query = """
SELECT c.email, COUNT(o.id) AS paid_orders, COALESCE(SUM(o.total), 0) AS revenue
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
GROUP BY c.id
ORDER BY revenue DESC
"""
for row in db.execute(query):
    print(row)
In the lesson you can change this and run it.

c and o are short aliases for the tables. The status check sits in the join's ON condition, so customers without paid orders still appear, with no matching order. COALESCE replaces Sam's NULL sum with 0. Move o.status = 'paid' into a WHERE clause and Sam would vanish, because WHERE runs after the join and drops his row.

Quick check
Which query counts orders per status?
  1. SELECT status FROM orders ORDER BY COUNT(*)
  2. SELECT * FROM orders WHERE COUNT(*) > 0
  3. SELECT status, COUNT(*) FROM orders GROUP BY status
  4. SELECT COUNT(status) FROM orders
Show the answer

SELECT status, COUNT(*) FROM orders GROUP BY status

GROUP BY status makes one row per status, and COUNT(*) counts the rows in each group.

Placeholders, always: SQL injection

Imagine a form with a "name" box, and someone writes "and also hand over the safe key" in it. A careless clerk reads the whole form aloud and obeys. Never build SQL by pasting values into a string. Here's why:

import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users (email TEXT, is_admin INTEGER);
INSERT INTO users VALUES ('ada@shop.test', 1), ('lin@shop.test', 0);
""")

typed = "nobody' OR '1'='1"          # what an attacker types into a login form

unsafe = f"SELECT email FROM users WHERE email = '{typed}'"
print("unsafe:", db.execute(unsafe).fetchall())      # returns EVERY user

safe = db.execute("SELECT email FROM users WHERE email = ?", (typed,)).fetchall()
print("safe:  ", safe)                               # no match, as it should be

The attacker's quote mark ended the string early and added OR '1'='1', which is always true. That's SQL injection, one of the most damaging and common security bugs ever found. With a placeholder (? in SQLite, %s in many Postgres drivers), the value travels separately from the SQL. The database never treats it as code, whatever characters it contains.

In plain words: the query is the form, and the user's text only ever goes in the box.

The same rule applies to SQL that a model writes. Run it with a read-only account, and never paste user text into it.

Quick check
Which line safely looks up a customer by an email a user typed?
  1. db.execute("SELECT * FROM customers WHERE email = ?", (email,))
  2. db.execute("SELECT * FROM customers WHERE email = '%s'" % email)
  3. db.execute("SELECT * FROM customers WHERE email = '" + email + "'")
  4. db.execute(f"SELECT * FROM customers WHERE email = '{email}'")
Show the answer

db.execute("SELECT * FROM customers WHERE email = ?", (email,))

Only the placeholder version keeps the value separate from the SQL. The other three paste text into the query.

Indexes: fast lookups

Without help, finding WHERE email = 'ada@shop.test' means checking every row: O(n). An index is a sorted structure on a column, usually a B-tree, a tree with many branches per node. It finds a row in O(log n), like the index at the back of a book. Primary keys and UNIQUE columns get one automatically. You add others for columns you search or join on often:

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL)")
plan = "EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 7"
print(db.execute(plan).fetchall()[0][-1])
db.execute("CREATE INDEX orders_by_customer ON orders(customer_id)")
print(db.execute(plan).fetchall()[0][-1])

SCAN reads the whole table. SEARCH ... USING INDEX jumps straight to the rows. Indexes cost disk space and slow down writes a little, the way a book's index has to be redone when the book changes. So add them for real queries, not every column.

Transactions: all or nothing

A vending machine either takes your money and gives you the snack, or you keep your money. Nobody wants just one half. Moving stock from one warehouse to another is two updates. If the program crashes between them, stock vanishes. A transaction groups statements so they all happen, or none do. You commit to keep the changes, or roll back to undo them all. In Python, with db: commits when the block ends and rolls back if an exception escapes.

Databases promise four properties, called ACID: changes are atomic (all or nothing), keep the data consistent with its rules, are isolated from other users' unfinished changes, and are durable once committed.

Note: You'll also hear about NoSQL databases (document stores such as MongoDB, key-value stores such as Redis) and ORMs such as SQLAlchemy, which build SQL from Python classes. They rest on the same ideas: keys, indexes and transactions.

Quick check
A transfer moves money with two UPDATE statements: subtract from one account, add to another. Why wrap them in a transaction?
  1. To hide the change from the logs
  2. To make each statement faster
  3. Because UPDATE needs a transaction to run
  4. So both happen or neither does, even if the program crashes between them
Show the answer

So both happen or neither does, even if the program crashes between them

Without a transaction, a crash after the first UPDATE would make money vanish. A transaction is all or nothing.

Three bugs that don't raise errors

f-strings in queries. "It's only from our own app" is how injection happens, since values come from users eventually. Always use placeholders.

The LEFT JOIN that stops being one. A LEFT JOIN keeps customers with no orders. Add WHERE orders.total > 10, and those customers vanish, because their total is NULL, which fails the test. The query quietly becomes an inner join. Put that condition in the ON clause to keep them.

Two statements with no transaction. Moving money takes a subtract and an add. If the program dies between them, the money has gone. Wrap them in a transaction, so both happen or neither does.

And don't index every column. Each index speeds up some reads and slows down every write. Add indexes for the queries you really run.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 6 review cards for spaced repetition

Create a free account

Try a quiz question

What's a foreign key?

  1. A column holding another table's primary key, linking the rows
  2. A key from another country's database, used to sync the two
  3. A password for the database, kept apart from the application's own
  4. The first column of every table, which numbers its rows in order

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson