Index Ai Academy
Phase 8 · APIs · Building APIs

A database-backed API

CRUD endpoints on SQLite, with safe concurrent updates.

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

Real APIs keep their data in a database, and many users change it at once. That's where the subtle bugs live. Two people edit the same record and one change silently disappears. A PATCH lets a user set a field they shouldn't. A duplicate slips past a check. This lesson connects the REST design from two lessons ago to the SQL from Phase 7, and adds the techniques that keep shared data correct.

What you'll be able to do

  • Map REST operations onto SQL through a repository layer
  • Build PATCH updates safely from an allowlist of fields
  • Prevent lost updates with version numbers, ETags and If-Match
  • Turn database outcomes into the right status codes: 201, 404, 409, 412, 428

What it covers

  1. From routes to SQL
  2. The lost update
  3. A conflict, caught
  4. Practical details
  5. Mapping database errors to useful answers

From routes to SQL

Think of a library's front desk. Visitors ask the librarian for a book, and only the librarian goes into the stacks. A repository class does that job in your code, using the pattern from the design lesson. Handlers ask it for things, and all the SQL lives inside it. Each REST operation maps onto one SQL statement:

requestrepository methodSQLsuccess
POST /campaignscreate(...)INSERT201 + Location
GET /campaigns/{id}get(id)SELECT ... WHERE id = ?200, or 404
PATCH /campaigns/{id}update(id, changes)UPDATE ... SET ... WHERE id = ?200, or 404
DELETE /campaigns/{id}delete(id)DELETE ... WHERE id = ?204, or 404

Handlers stay short: parse and validate, call the repository, turn the result into a response. Database rules become status codes too. A UNIQUE constraint failing on a duplicate email becomes 409 Conflict, not a 500.

PATCH: build the update from an allowlist

Picture a form where staff may change your address, but not your account balance. PATCH needs the same rule. It sends only the fields to change, such as {"budget": 750}, so the UPDATE statement has to be built from whichever keys arrive.

Values always go in placeholders, the ? marks that keep data apart from the SQL. But column names can't be placeholders, because they're part of the statement itself. So they must come from your own fixed list, an allowlist, and never straight from the request. Otherwise a client could send {"is_admin": 1}, or a key containing SQL.

EDITABLE = {"name", "budget", "status"}          # the allowlist

def build_update(changes):
    unknown = set(changes) - EDITABLE
    if unknown:
        raise ValueError(f"can't change: {', '.join(sorted(unknown))}")
    columns = sorted(changes)                     # sorted, so the SQL is predictable
    assignments = ", ".join(f"{col} = ?" for col in columns)
    return f"UPDATE campaigns SET {assignments} WHERE id = ?", [changes[c] for c in columns]

print(build_update({"budget": 750, "status": "active"}))
try:
    build_update({"budget": 750, "owner_id": 1})
except ValueError as err:
    print("rejected:", err)
It runs right here in your browser, with nothing to install.

The column names in the SQL come only from EDITABLE, which you wrote. The values travel separately as placeholders. Allowlisting like this also stops mass assignment: a client quietly setting fields such as role or price_paid that it was never meant to touch.

Quick check
Why can't you write UPDATE campaigns SET ? = ? WHERE id = ? and pass the column name as a placeholder?
  1. SQLite only allows one placeholder per statement
  2. Column names must be uppercase
  3. You can; it's the safest way
  4. Placeholders only stand for values, not names of columns or tables
Show the answer

Placeholders only stand for values, not names of columns or tables

The database plans the query before it sees the values, so names must be in the SQL itself. That's why they must come from an allowlist you control.

The lost update

Ada and Lin both open campaign 7, budget 500. Ada changes the name and saves. Lin, looking at an older copy, changes the budget and saves the whole record. Lin's save writes back the old name. Ada's change has vanished, and nobody got an error. That's a lost update, and with many users it happens every day.

Optimistic concurrency with versions

Think of a shared recipe card with a version number in the corner. Before you write your changes on it, you check the number is still the one you read. If it's gone up, someone has changed the card, and you read it again first.

In a database, give each row a version number that goes up on every change. A client says which version it started from, and the update only applies if that's still the current one:

UPDATE campaigns SET budget = ?, version = version + 1
WHERE id = ? AND version = ?

If someone else saved in between, the version no longer matches and no rows change. The API reports a conflict, and the client reloads and tries again. It's called optimistic because it assumes conflicts are rare and only checks at save time, instead of locking the record while someone edits it.

ETags and If-Match

HTTP has headers for exactly this. A GET response carries an ETag, a short tag for this version of the resource, such as "7-3". To update, the client sends it back in an If-Match header, which means "only do this if it's still that version":

  • If it matches the current ETag, the update goes ahead, and the response carries the new ETag.
  • If it doesn't, the answer is 412 Precondition Failed: "someone changed this; reload first".
  • If an API requires If-Match and it's missing, the answer is 428 Precondition Required.

ETags help reads too. A client sends If-None-Match with the ETag it has, meaning "only send it if it's changed". If nothing changed, the server answers 304 Not Modified with no body, which saves bandwidth.

Quick check
What does this print?
row = {"version": 4}
client_version = 3
print("updated" if client_version == row["version"] else "conflict")
  1. 3
  2. conflict
  3. 4
  4. updated
Show the answer

conflict

3 isn't equal to 4, so it prints conflict. Someone else changed the row since this client read it, so the update is refused.

Try it now

Optimistic concurrency: no locks, and nobody's change quietly vanishes.

Quick check
Two admins load the same campaign at version 3 and both save changes. With no version check, what happens?
  1. The first save wins automatically
  2. Both saves fail
  3. The database merges both changes
  4. The second save silently overwrites the first: a lost update
Show the answer

The second save silently overwrites the first: a lost update

Each save writes what that admin saw plus their change. A version number (or If-Match) lets the API refuse the stale one with 409.

Worked example

A conflict, caught

Two clients read version 1. The first update succeeds; the second matches no rows.

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE campaigns (id INTEGER PRIMARY KEY, name TEXT, budget REAL, version INTEGER NOT NULL DEFAULT 1)")
db.execute("INSERT INTO campaigns (name, budget) VALUES ('Spring', 500)")

def update_budget(cid, budget, expected_version):
    cur = db.execute(
        "UPDATE campaigns SET budget = ?, version = version + 1 WHERE id = ? AND version = ?",
        (budget, cid, expected_version),
    )
    return cur.rowcount == 1

ada_saw = lin_saw = 1                       # both loaded version 1
print("Ada saves:", update_budget(1, 650, ada_saw))
print("Lin saves:", update_budget(1, 400, lin_saw))
print(db.execute("SELECT budget, version FROM campaigns").fetchone())
In the lesson you can change this and run it.

Ada's update matched (version 1) and bumped the version to 2. Lin's update still said "version 1", so it matched nothing and rowcount was 0. The API would answer 412 or 409, and Lin reloads, sees Ada's change, and decides again. Nothing is silently lost.

Quick check
A client PATCHes with If-Match: "7-3", but the campaign is now at version 4. What should the API answer?
  1. 412 Precondition Failed
  2. 500 Internal Server Error
  3. 404 Not Found
  4. 200 OK, applying the change anyway
Show the answer

412 Precondition Failed

The client's copy is out of date. 412 tells it to fetch the latest version and try again.

Practical details

  • Timestamps: store created_at and updated_at in UTC as ISO 8601 text (2026-10-30T14:05:00Z). Convert to local time only when showing it to a person.
  • Transactions: an operation that touches several rows, such as creating an order and lowering stock, goes in one transaction. It's like a bank transfer: both halves happen, or neither does. A failure halfway leaves nothing behind.
  • Soft delete: many APIs set deleted_at instead of removing the row, like moving a file to the trash. Mistakes can be undone and history survives. Every query then has to skip deleted rows, so it's a trade-off.
  • Return the new state: after a create or update, return the full resource, with its new ETag. That saves the client a second request.

Mapping database errors to useful answers

When a unique constraint fails, the database raises an error. Left alone, that becomes a 500, telling the client your server broke. It didn't. They tried to use an email address that's already taken. Catch that specific error and return a 409 with a clear message. The constraint is still what keeps your data correct, since checking first and inserting after leaves a gap that two requests can slip through together.

Never build column names from a request

Placeholders protect values, but SQL doesn't allow placeholders for column names. So code that loops over a request's keys and pastes them into UPDATE is open to injection through the key itself. That's why PATCH builds its update from an allowlist of known columns.

Store time in UTC

Save every time in UTC, and convert to local time only for display. Local times have gaps and repeats when the clocks change, so one wall-clock time can mean two different moments. And times from two servers in different zones can't be compared safely. This is easy to do from the start, and very painful to fix later.

Practice what you just read

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

Create a free account

Try a quiz question

What is a lost update?

  1. Two clients edit the same record and one's change is silently overwritten by the other's
  2. An update that fails with a 500 error, so the client has to send it again from the start
  3. A record deleted by mistake during an update, which then can't be recovered from backups
  4. An update that takes so long that the client gives up and moves on before it has finished

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

Before this lesson