Skip to content

All lessons

Area 08 // BackendLesson 132 / 14016 min read

SQL vs NoSQL, indexing and transactions

Databases store your app's lasting data (users, conversations, documents, usage), where SQL databases keep structured tables with relations and strong guarantees, NoSQL stores trade some of that for flexible or massive-scale access, indexes make lookups fast, and transactions make groups of changes all-or-nothing.

Think of it like this

Analogy

A SQL database is a well-run filing office with labelled cabinets, cross-references between folders, and rules that every form must be complete. NoSQL stores are specialised: a coat-check (key-value, instant lookup by ticket), a scrapbook of flexible pages (documents), a map of who knows whom (graph). An index is the alphabetical tab system that lets you jump to "Smith" instead of reading every folder. A transaction is the rule that a bank transfer updates both accounts or neither.

The problem it solves

AI apps have plenty of ordinary data: users, conversations and messages, uploaded documents and their chunks, usage and cost records, feedback, eval results. Pick the wrong store, forget indexes, or update related rows without transactions, and you get slow pages, lost messages, double-charged credits and painful migrations. Vector search adds one more question: separate vector database or not (vector databases)?

How it works: SQL vs NoSQL

  1. SQL (relational): data in tables with typed columns; relations via foreign keys and joins; constraints (unique, not null, checks); a powerful query language; strong transactions. Postgres is the common default; SQLite for local and embedded use.
  2. NoSQL families:
    • Key-value (Redis, DynamoDB): very fast lookups by key (Redis caching).
    • Document (MongoDB, Firestore): flexible JSON-like records.
    • Wide-column (Cassandra): huge write volumes across many machines.
    • Graph (Neo4j): relationship-heavy queries (GraphRAG).
  3. Choose by access pattern: how you read and write, consistency needs, scale. Most apps start well with Postgres and add specialised stores only for clear needs.
  4. For AI: Postgres with pgvector stores relational data and embeddings together, with filters and transactions (RAG access control); JSONB columns hold flexible metadata like tool-call traces.

How it works: database indexing

  1. Without an index, a query scans every row: fine for 1,000 rows, painful for 100 million.
  2. An index (usually a B-tree) is a sorted structure on one or more columns, letting the database jump to matching rows.
  3. Index what you filter, join and sort by: WHERE conversation_id = ? ORDER BY created_at wants a composite index on (conversation_id, created_at).
  4. Check with EXPLAIN (see the code): look for scans on big tables.
  5. Costs: storage and slower writes; don't index everything.
  6. Special indexes: full-text search, JSONB, and vector indexes (HNSW, IVFFlat) for similarity search.

How it works: transactions and ACID

  1. A transaction groups several statements: BEGIN → changes → COMMIT (or ROLLBACK).
  2. ACID: Atomic (all or nothing), Consistent (constraints hold), Isolated (concurrent transactions don't see each other's half-done work), Durable (committed data survives crashes).
  3. Use them whenever several writes must agree: deduct credits and record usage; save a message and update the conversation's timestamp; mark a job done and store its result.
  4. Keep them short: never hold a transaction open while calling an LLM.
  5. Concurrency: use constraints, row locks (SELECT ... FOR UPDATE) or idempotency keys (idempotency) to avoid race conditions.

See it in code

Needs
Nothing: plain Python 3 (sqlite3 is built in). Postgres behaves the same way with its own EXPLAIN output.

A 100,000-message table queried before and after adding an index, then a credit transfer that rolls back when it would break a rule.

db_basics.py
import sqlite3
 
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, conversation_id INTEGER, role TEXT, content TEXT)")
db.executemany("INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?)",
               [(i % 5_000, "user" if i % 2 else "assistant", f"message {i}") for i in range(100_000)])
 
# INDEXING: without an index the database scans every row; with one it jumps straight to the matches.
query = "SELECT content FROM messages WHERE conversation_id = ? ORDER BY id"
plan = lambda: db.execute("EXPLAIN QUERY PLAN " + query, (42,)).fetchall()[0][-1]
print("before index:", plan())
db.execute("CREATE INDEX idx_messages_conversation ON messages (conversation_id, id)")
print("after index :", plan())
 
# TRANSACTIONS: several writes succeed together or not at all (atomicity).
db.execute("CREATE TABLE credits (user TEXT PRIMARY KEY, balance INTEGER CHECK (balance >= 0))")
db.executemany("INSERT INTO credits VALUES (?, ?)", [("alice", 100), ("bob", 0)])
db.commit()
def transfer(src, dst, amount):
    try:
        with db:                                                     # BEGIN ... COMMIT, or ROLLBACK on error
            db.execute("UPDATE credits SET balance = balance + ? WHERE user = ?", (amount, dst))
            db.execute("UPDATE credits SET balance = balance - ? WHERE user = ?", (amount, src))
        return "committed"
    except sqlite3.IntegrityError as e:
        return f"rolled back ({e})"
print(transfer("alice", "bob", 30), dict(db.execute("SELECT * FROM credits")))
print(transfer("alice", "bob", 500), dict(db.execute("SELECT * FROM credits")))
Output
before index: SCAN messages
after index : SEARCH messages USING INDEX idx_messages_conversation (conversation_id=?)
committed {'alice': 70, 'bob': 30}
rolled back (CHECK constraint failed: balance >= 0) {'alice': 70, 'bob': 30}

In the failed transfer, Bob's balance had already been increased by the first statement; the rollback undid it, so no credits appeared out of nowhere.

A real example

Example

The conversation page that took six seconds

A chat app stored 40 million messages in Postgres. Opening a long conversation took six seconds because the query filtered by conversation_id and sorted by created_at with no matching index: every load scanned a huge range. One composite index brought it to 15 ms. Separately, users occasionally saw negative credit balances: credit deduction and usage logging ran as two separate statements without a transaction, and concurrent requests raced. Wrapping both in a transaction with a CHECK (balance >= 0) constraint and row locking fixed it.

The easy way

Start here

Use managed Postgres. Model users, conversations, messages and documents as tables with foreign keys; add pgvector for embeddings; index foreign keys and the columns you filter and sort by; wrap related writes in transactions; use a migration tool to change the schema.

When you grow: the production setup

Teams use managed Postgres with connection pooling, read replicas, schema migrations in CI, query monitoring and slow-query alerts, partitioning for huge tables (messages, usage events), point-in-time backups and restore drills, row-level security for multi-tenant data, and specialised stores (Redis, a dedicated vector database, a warehouse for analytics) only when measurements justify them.

Trade-offs

What you gain

  • SQL gives strong guarantees, flexible queries and mature tooling.
  • Indexes turn slow scans into millisecond lookups.
  • Transactions prevent half-finished updates.

What it costs you

  • Schemas need migrations as the app changes.
  • Indexes cost storage and write speed.
  • Scaling writes across many machines is harder for SQL than for some NoSQL stores.

When not to use it

  • Don't add a NoSQL store "for scale" before you have a measured need.

Common mistakes

  • No indexes on foreign keys and filter columns.
  • Holding a database transaction open during an LLM call.
  • Several related writes without a transaction.
  • Picking a database for hype instead of access patterns.
  • Storing everything in one JSON column and losing constraints and queryability.

Interview questions

Try answering out loud first, then open the model answer.

SQL or NoSQL for an AI chat application?

Model answer

Usually SQL (Postgres): users, conversations, messages, documents and usage are relational, need constraints and transactions, and are queried in varied ways; JSONB handles flexible metadata and pgvector handles embeddings. Add Redis for caching and rate limits, and consider NoSQL or specialised stores only for specific patterns at large scale (e.g. huge event streams).

How do you find and fix a slow query?

Model answer

Measure (slow-query logs, APM), run EXPLAIN (ANALYZE) to see the plan, look for sequential scans, bad join orders or sorts on large tables, add or adjust indexes (composite indexes matching filter and sort order), rewrite the query, add pagination, and re-measure. Consider caching for expensive repeated reads.

What are ACID transactions and when do you need them?

Model answer

Atomicity (all or nothing), consistency (constraints always hold), isolation (concurrent transactions don't interfere), durability (committed changes persist). You need them whenever multiple writes must stay consistent: payments and credits, creating related records, job state changes.

Quiz

Pick an answer to see whether it’s right and why.

  1. 01What is a sensible default database for a new AI app?
  2. 02What does an index on conversation_id do?
  3. 03What's the cost of adding indexes?
  4. 04What does atomicity in ACID mean?
  5. 05When does a NoSQL store make sense?

0 of 5 answered

Key terms

SQL database
A relational database that stores data in tables with columns, relations and constraints, queried with SQL. Postgres, MySQL and SQLite are examples.
NoSQL
Databases that don't use the relational table model, such as key-value, document, wide-column and graph stores, often chosen for a specific access pattern or scale.
Database index
An extra sorted structure on one or more columns that lets the database find matching rows without scanning the whole table.
Transaction
A group of database changes that are applied together or not at all.
ACID
The guarantees of database transactions, which are Atomicity, Consistency, Isolation and Durability.

Before and after

Progress is saved in this browser only.