Databases: SQLite, Postgres, and Per-User Schema Design

Learning Objectives

Pre-Quiz: Relational Basics

A developer tries to insert a messages row whose conversation_id refers to a conversation that was never created, and the database rejects the insert. Which mechanism is doing this?

A primary key guaranteeing the message ID is unique
A foreign key enforcing referential integrity against the conversations table
An index speeding up the lookup of the conversation
A transaction rolling back the insert atomically

Your gateway runs as a single process on one machine, is overwhelmingly read-heavy, and you want tests to run against a real database in milliseconds with zero setup. Which choice best fits, and why?

Postgres, because it uses MVCC so readers never block writers
SQLite, because it is an in-process single-file library needing no server
Postgres, because SQLite cannot provide ACID transactions
SQLite, because it supports unlimited simultaneous writers per file

Recording a message inserts into messages and updates last_message_at on the conversation. The process crashes between the two writes. Which ACID property ensures the database is not left with a "last message" timestamp for a message that was never stored?

Durability
Isolation
Atomicity
Consistency of data types

1. Relational Basics

Key Points

The relational model organizes data into tables. Each row (a tuple) is one record — one user, one message — and each column is a typed field shared by every row. Two kinds of keys give tables their power. A primary key uniquely identifies each row (no two users share a user_id). A foreign key is a column that must match a primary key in another table, enforcing referential integrity: the database refuses to insert a message pointing at a conversation that does not exist. Relationships come in three shapes: one-to-one, one-to-many (a conversation has many messages), and many-to-many (users and conversations), resolved with a linking table.

SQLite and Postgres both speak SQL and provide ACID transactions, but sit at opposite ends of the deployment spectrum. SQLite is an embedded library that reads and writes a single file inside your own process; the project frames it as competing "with fopen()," not with a database server. Postgres is a separate server process you connect to over a socket or network. Concurrency is the decisive difference: SQLite supports unlimited readers but only one writer at a time (WAL mode lets readers proceed alongside that writer), while Postgres uses MVCC (Multi-Version Concurrency Control) so readers and writers never block each other and hundreds of clients can write simultaneously.

DimensionSQLitePostgreSQL
ArchitectureEmbedded library, single file in-processSeparate client/server process over socket/network
ConcurrencyUnlimited readers, one writer at a timeMVCC: readers and writers never block each other
SetupZero — no server, no portsRun a server, manage connections and auth
TestingReal DB in :memory:, millisecondsNeeds a running instance or container
BackupCopy the filepg_dump / streaming replication
Advanced featuresCore SQL, JSON1JSONB + GIN, full-text search, PostGIS, RLS, window functions
Best fitLocal dev, tests, single-node prototypesProduction with concurrent writers and isolation

A transaction is a group of operations treated as one indivisible unit, guaranteed to be ACID. Atomicity: all succeed or none do. Consistency: constraints always hold. Isolation: concurrent transactions do not see each other's uncommitted changes. Durability: committed data survives a crash. Atomicity is why we wrap "insert message + bump last_message_at" in one transaction — a crash between the two writes can never leave the timestamp lying about a message that was not stored. Isolation levels (default READ COMMITTED, stricter SERIALIZABLE) trade consistency against throughput; for a chat gateway the defaults are almost always correct.

Visual animation — coming soon

Post-Quiz: Relational Basics

A developer tries to insert a messages row whose conversation_id refers to a conversation that was never created, and the database rejects the insert. Which mechanism is doing this?

A primary key guaranteeing the message ID is unique
A foreign key enforcing referential integrity against the conversations table
An index speeding up the lookup of the conversation
A transaction rolling back the insert atomically

Your gateway runs as a single process on one machine, is overwhelmingly read-heavy, and you want tests to run against a real database in milliseconds with zero setup. Which choice best fits, and why?

Postgres, because it uses MVCC so readers never block writers
SQLite, because it is an in-process single-file library needing no server
Postgres, because SQLite cannot provide ACID transactions
SQLite, because it supports unlimited simultaneous writers per file

Recording a message inserts into messages and updates last_message_at on the conversation. The process crashes between the two writes. Which ACID property ensures the database is not left with a "last message" timestamp for a message that was never stored?

Durability
Isolation
Atomicity
Consistency of data types
Pre-Quiz: Schema Design for a Gateway

One human uses both Slack and Discord, and the gateway must remember them as a single person across both. What design makes this work?

Store the Slack and Discord usernames directly in every message row
A channels table with UNIQUE (platform, platform_user_id) mapping each identity to one internal user_id
A separate users table per platform, joined at query time
A composite index on messages(conversation_id, sent_at)

Why does the schema store only sender_id on each message and join to users for the name, rather than storing sender_username inline in every message row?

Because storing text is not allowed in a normalized column
Because a rename would otherwise force updating the name in thousands of rows — an update anomaly
Because foreign keys forbid any text columns on child tables
Because joins are always faster than reading a stored column

The dominant read query is "fetch the most recent N messages in a conversation, paginated." Which single addition best serves it, and what SQLite-specific caveat must you also remember for foreign keys?

An index on messages(content); no SQLite caveat applies
A composite index on messages(conversation_id, sent_at); and PRAGMA foreign_keys = ON must be set per connection in SQLite
A denormalized last_message_at column; SQLite enforces foreign keys automatically
A primary key on message_id alone; foreign keys need no configuration

2. Schema Design for a Gateway

Key Points

A chat backend maps cleanly onto the relational model. The gateway uses five related tables. A users table holds one row per account, independent of platform. A channels table records each platform integration plus the platform-specific identity, so one human on both Slack and Discord resolves to a single user_id. A conversations table holds one row per thread with a type column and a denormalized last_message_at. A conversation_participants junction resolves the many-to-many between users and conversations. A messages table holds one row per turn, carrying a role column that aligns with LLM chat-completion formats.

Figure 11.1: Entity-Relationship diagram of the gateway schema. Read the crow's-foot ||--o{ as "has many."

erDiagram users { bigserial user_id PK text display_name text email UK text status timestamptz created_at } channels { bigserial channel_id PK bigint user_id FK text platform text platform_user_id timestamptz created_at } conversations { bigserial conversation_id PK text type text title timestamptz created_at timestamptz last_message_at } conversation_participants { bigint conversation_id PK,FK bigint user_id PK,FK timestamptz joined_at timestamptz last_read_at } messages { bigserial message_id PK bigint conversation_id FK bigint sender_id FK text role text content timestamptz sent_at } users ||--o{ channels : "has platform identity" users ||--o{ conversation_participants : "is member of" conversations ||--o{ conversation_participants : "has member" conversations ||--o{ messages : "contains" users ||--o{ messages : "sends (sender_id)"

Here is the worked schema (Postgres syntax; BIGSERIAL becomes INTEGER PRIMARY KEY and TIMESTAMPTZ becomes TEXT in SQLite):

CREATE TABLE users (
    user_id       BIGSERIAL PRIMARY KEY,
    display_name  TEXT        NOT NULL,
    email         TEXT        UNIQUE,
    status        TEXT        NOT NULL DEFAULT 'active',
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE channels (
    channel_id        BIGSERIAL PRIMARY KEY,
    user_id           BIGINT      NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    platform          TEXT        NOT NULL,          -- 'slack' | 'discord' | 'webex'
    platform_user_id  TEXT        NOT NULL,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (platform, platform_user_id)              -- one identity maps to one user
);

CREATE TABLE conversations (
    conversation_id  BIGSERIAL PRIMARY KEY,
    type             TEXT        NOT NULL DEFAULT 'direct',  -- 'direct' | 'group'
    title            TEXT,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_message_at  TIMESTAMPTZ                             -- denormalized for inbox sort
);

CREATE TABLE conversation_participants (
    conversation_id  BIGINT NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE,
    user_id          BIGINT NOT NULL REFERENCES users(user_id)                 ON DELETE CASCADE,
    joined_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_read_at     TIMESTAMPTZ,
    PRIMARY KEY (conversation_id, user_id)           -- composite PK: no duplicate members
);

CREATE TABLE messages (
    message_id       BIGSERIAL PRIMARY KEY,
    conversation_id  BIGINT      NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE,
    sender_id        BIGINT              REFERENCES users(user_id),   -- NULL for assistant/system
    role             TEXT        NOT NULL,                            -- 'user'|'assistant'|'system'
    content          TEXT        NOT NULL,
    sent_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

Normalization stores each fact exactly once. Third Normal Form (3NF) — the practical target — means non-key columns depend only on the primary key. Do not store sender_username inline in every message; a rename would force updating thousands of rows (an update anomaly). Store only sender_id and join. The one sanctioned denormalization is last_message_at, kept in sync on each insert so the inbox sorts without scanning messages.

ON DELETE CASCADE removes participant rows and messages when a conversation is deleted, preventing orphans. Critical SQLite caveat: foreign key enforcement is off by default and must be enabled per connection with PRAGMA foreign_keys = ON;. Postgres enforces unconditionally. An index lets the database find rows without scanning the whole table (like a book's back-of-index). The dominant query — recent N messages per conversation, paginated — is served by a composite index on messages(conversation_id, sent_at). Also index foreign-key columns, because Postgres does not auto-index the referencing side.

CREATE INDEX idx_messages_conv_time ON messages (conversation_id, sent_at);
CREATE INDEX idx_messages_sender    ON messages (sender_id);
CREATE INDEX idx_participants_user  ON conversation_participants (user_id);

Linking accounts: when an event arrives, ingress looks up (platform, platform_user_id) in channels. A hit yields the internal user_id; a miss creates a new users row and a channels row in one transaction. The UNIQUE (platform, platform_user_id) constraint guarantees each platform identity resolves to exactly one internal user, while multiple channels rows can point at the same user_id — that is account linking. From then on, every message and conversation attaches to the stable internal ID, not the volatile platform ID.

Post-Quiz: Schema Design for a Gateway

One human uses both Slack and Discord, and the gateway must remember them as a single person across both. What design makes this work?

Store the Slack and Discord usernames directly in every message row
A channels table with UNIQUE (platform, platform_user_id) mapping each identity to one internal user_id
A separate users table per platform, joined at query time
A composite index on messages(conversation_id, sent_at)

Why does the schema store only sender_id on each message and join to users for the name, rather than storing sender_username inline in every message row?

Because storing text is not allowed in a normalized column
Because a rename would otherwise force updating the name in thousands of rows — an update anomaly
Because foreign keys forbid any text columns on child tables
Because joins are always faster than reading a stored column

The dominant read query is "fetch the most recent N messages in a conversation, paginated." Which single addition best serves it, and what SQLite-specific caveat must you also remember for foreign keys?

An index on messages(content); no SQLite caveat applies
A composite index on messages(conversation_id, sent_at); and PRAGMA foreign_keys = ON must be set per connection in SQLite
A denormalized last_message_at column; SQLite enforces foreign keys automatically
A primary key on message_id alone; foreign keys need no configuration
Pre-Quiz: Per-User Data Design

In shared-schema multi-tenancy, what is the fundamental weakness that Postgres Row-Level Security (RLS) is designed to eliminate?

Tables grow too large to index efficiently
Isolation lives in application code, so a forgotten WHERE user_id leaks one user's data to another
Migrations must be applied across thousands of separate schemas
Foreign keys cannot span multiple tenants

A handler scopes its query using a user_id taken from a field in the incoming request body. Why is this a serious isolation flaw even if RLS and a data-access layer are also present?

Request bodies cannot contain integers
An attacker can supply any user_id, defeating every isolation mechanism above the database
RLS overrides the request body automatically, so it is merely redundant
The user_id must be a string, not an integer, to be safe

On a live production Postgres gateway, why prefer an additive, backward-compatible migration (add a nullable column, backfill, then enforce) over a single destructive change?

Additive changes cannot fail, so no timeouts are needed
Destructive changes can take locks that block writes and freeze the application; additive steps avoid that
Nullable columns are always faster to query than non-nullable ones
Backward-compatible migrations do not need to be versioned

3. Per-User Data Design

Key Points

Multi-tenancy serves many independent users from one application while keeping each tenant's data separate. The shared-schema approach stores every tenant's rows in the same tables, distinguished by a tenant_id (here, effectively user_id) column; every query filters WHERE user_id = $1. It is the simplest to operate and scales to enormous tenant counts, but isolation lives in application code — forget the filter and you leak. The schema-per-tenant approach gives strong logical isolation but bloats system catalogs and makes migrations slow across thousands of schemas.

The modern default is shared schema hardened with Postgres Row-Level Security (RLS): a policy attached to a table so the engine itself appends the tenant filter to every query. RLS eliminates the "forgotten WHERE tenant_id" bug at the database level — even a query that omits the filter returns only the current tenant's rows.

Figure 11.2: Shared-schema row isolation — every query scoped to the authenticated user.

flowchart TD req["Inbound request
(authenticated user_id from ingress)"] --> setctx["Set app.current_user_id
at start of request"] setctx --> query["Query: SELECT ... FROM messages"] query --> rls{"RLS policy:
row's conversation
includes current user?"} rls -->|"yes"| ownrows["Return rows for
this user's conversations"] rls -->|"no"| blocked["Rows filtered out
(other tenants invisible)"] subgraph shared["Shared messages table (all tenants)"] rowA["Rows: user 101's conversations"] rowB["Rows: user 202's conversations"] rowC["Rows: user 303's conversations"] end ownrows -.->|"reads only"| rowA blocked -.->|"never returned"| rowB blocked -.->|"never returned"| rowC
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

CREATE POLICY messages_isolation ON messages
    USING (
        conversation_id IN (
            SELECT conversation_id FROM conversation_participants
            WHERE user_id = current_setting('app.current_user_id')::bigint
        )
    );

Two disciplines defend against cross-user leakage — the most dangerous class of bug because it is silent, a privacy breach, and can persist undetected. First, make the tenant filter unavoidable: funnel all access through a thin data-access layer whose functions always take a user_id and always include it in the WHERE clause, backed by RLS as the final line of defense. Second, never trust the client's claimed identity: the user_id must come from the authenticated identity established at ingress (Chapter 5), not from a request-body field. A user who can pass an arbitrary user_id has defeated every isolation mechanism above it.

A schema is never finished. A migration is a versioned, ordered script that applies one change so every environment reaches the same state deterministically. Tools like Alembic record which migrations ran and apply pending ones in order. Migrations also execute the SQLite-to-Postgres graduation without surprises. Production migrations carry an operational hazard: locking. Adding a foreign key on Postgres takes a lock that can block writes while it validates rows. Set lock_timeout and statement_timeout so a migration fails fast rather than freezing the app, and prefer additive, backward-compatible changes (add a nullable column, backfill, then enforce) over destructive ones.

Visual animation — coming soon

Post-Quiz: Per-User Data Design

In shared-schema multi-tenancy, what is the fundamental weakness that Postgres Row-Level Security (RLS) is designed to eliminate?

Tables grow too large to index efficiently
Isolation lives in application code, so a forgotten WHERE user_id leaks one user's data to another
Migrations must be applied across thousands of separate schemas
Foreign keys cannot span multiple tenants

A handler scopes its query using a user_id taken from a field in the incoming request body. Why is this a serious isolation flaw even if RLS and a data-access layer are also present?

Request bodies cannot contain integers
An attacker can supply any user_id, defeating every isolation mechanism above the database
RLS overrides the request body automatically, so it is merely redundant
The user_id must be a string, not an integer, to be safe

On a live production Postgres gateway, why prefer an additive, backward-compatible migration (add a nullable column, backfill, then enforce) over a single destructive change?

Additive changes cannot fail, so no timeouts are needed
Destructive changes can take locks that block writes and freeze the application; additive steps avoid that
Nullable columns are always faster to query than non-nullable ones
Backward-compatible migrations do not need to be versioned
Pre-Quiz: Async Database Access

Why is using a native async driver like asyncpg a correctness requirement, not just a performance nicety, in the asyncio gateway?

Async drivers are the only ones that support parameterized queries
A synchronous driver waiting on a socket blocks the event loop, freezing every other coroutine
Only async drivers can talk to Postgres over the network
Synchronous drivers cannot open more than one connection

Why create a single process-wide connection pool at startup with a bounded max_size, rather than opening a fresh connection per request?

Pools disable transactions, which speeds up writes
Reusing warm connections avoids costly per-request setup, and max_size bounds the load placed on the database
A per-request connection cannot run parameterized queries
Pools are required for the database to enforce foreign keys

A chat gateway feeds arbitrary user text straight into queries. Which practice is the definitive defense against SQL injection, and why does it work?

Escaping quotes in the user text before formatting it into the SQL string
Passing values as bound parameters ($1, ?) so the database treats them as data, never as SQL
Wrapping the query in a transaction so a malicious statement rolls back
Running the query inside a connection pool, which sanitizes input automatically

You launch thousands of DB tasks with asyncio.gather and latency balloons as they all pile up on pool.acquire(). What is the recommended fix?

Raise max_size to match the number of tasks so none ever wait
Bound the fan-out with an asyncio.Semaphore so you never spawn more concurrent tasks than the pool can serve
Switch to a synchronous driver to serialize the work
Remove per-query timeouts so slow queries can finish

4. Async Database Access

Key Points

The cardinal asyncio rule applies with full force: a single blocking call stalls every concurrent coroutine. A synchronous driver waiting on a socket blocks the loop, so no other webhook is acknowledged and no worker progresses — the gateway freezes. asyncpg solves this for Postgres: written for the event loop, it performs genuinely non-blocking I/O, implements the Postgres binary wire protocol directly, and uses prepared statements by default. For SQLite in dev and tests, aiosqlite wraps calls in a background thread to present an async interface.

Pooling is essential. Opening a new Postgres connection is expensive (TCP handshake, TLS, auth, backend startup); reusing pooled connections can cut short-query latency by 10–100x. A connection pool maintains established connections that coroutines borrow and return; asyncpg ships pooling built in. Create a single, process-wide pool at startup — never per request. min_size keeps connections warm; max_size bounds the load your app can place on the database so a stampede of coroutines cannot open unlimited connections.

Figure 11.3: A coroutine borrowing and returning a pooled connection.

sequenceDiagram participant Coro as Coroutine participant Pool as asyncpg pool (max_size bound) participant Conn as Pooled connection participant PG as PostgreSQL server Coro->>Pool: async with pool.acquire() alt Idle connection available Pool-->>Coro: Hand out warm connection else All connections busy Pool-->>Coro: Wait until one is returned end Coro->>Conn: await conn.fetch(...) / execute(...) Conn->>PG: Send query (binary wire protocol) PG-->>Conn: Rows / result Conn-->>Coro: Awaited result Coro->>Pool: Exit context — auto-return connection Note over Pool,Conn: Connection reset and kept warm for reuse (no reconnect)
import asyncpg

pool: asyncpg.Pool | None = None

async def init_db() -> None:
    """Call once at application startup (e.g., a FastAPI lifespan handler)."""
    global pool
    pool = await asyncpg.create_pool(
        dsn="postgresql://user:pass@host/gateway",
        min_size=4,               # connections kept warm
        max_size=20,              # hard cap on concurrency to the DB
        statement_cache_size=512, # speeds repeated queries
    )

Borrow a connection with an async context manager (guaranteed return even on error) and wrap multi-statement writes in a transaction. Here the schema and async pool tie together — recording a message and bumping the conversation atomically:

async def store_message(conversation_id: int, sender_id: int,
                        role: str, content: str) -> int:
    async with pool.acquire() as conn:          # borrow, auto-return
        async with conn.transaction():          # atomic: both or neither
            message_id = await conn.fetchval(
                """
                INSERT INTO messages (conversation_id, sender_id, role, content)
                VALUES ($1, $2, $3, $4)
                RETURNING message_id
                """,
                conversation_id, sender_id, role, content,
            )
            await conn.execute(
                "UPDATE conversations SET last_message_at = now() "
                "WHERE conversation_id = $1",
                conversation_id,
            )
            return message_id

Several patterns keep the loop healthy. Bound fan-out: guard many concurrent DB tasks with an asyncio.Semaphore so you never spawn thousands of coroutines contending for max_size connections — otherwise they queue on acquire() and latency balloons. Set explicit timeouts with asyncio.timeout(...) so a slow query cannot pin a connection forever. And never mix a blocking sync driver into the async path; offload blocking code with run_in_executor.

Look again at every example: each user value is passed as a separate argument ($1, $2), not glued into the SQL string. This is a parameterized query — the database receives the template and the values separately, so a value can never be interpreted as SQL. SQL injection is the attack this defeats: build a query with f"... WHERE content = '{user_input}'" and an attacker sending '; DROP TABLE messages; -- injects their own SQL. Because a chat gateway feeds arbitrary user text into queries, this is the expected threat, not a theoretical one. asyncpg uses numbered placeholders ($1); aiosqlite uses ?; both bind safely. The rule is absolute: never interpolate user input into SQL, always bind it as a parameter.

Post-Quiz: Async Database Access

Why is using a native async driver like asyncpg a correctness requirement, not just a performance nicety, in the asyncio gateway?

Async drivers are the only ones that support parameterized queries
A synchronous driver waiting on a socket blocks the event loop, freezing every other coroutine
Only async drivers can talk to Postgres over the network
Synchronous drivers cannot open more than one connection

Why create a single process-wide connection pool at startup with a bounded max_size, rather than opening a fresh connection per request?

Pools disable transactions, which speeds up writes
Reusing warm connections avoids costly per-request setup, and max_size bounds the load placed on the database
A per-request connection cannot run parameterized queries
Pools are required for the database to enforce foreign keys

A chat gateway feeds arbitrary user text straight into queries. Which practice is the definitive defense against SQL injection, and why does it work?

Escaping quotes in the user text before formatting it into the SQL string
Passing values as bound parameters ($1, ?) so the database treats them as data, never as SQL
Wrapping the query in a transaction so a malicious statement rolls back
Running the query inside a connection pool, which sanitizes input automatically

You launch thousands of DB tasks with asyncio.gather and latency balloons as they all pile up on pool.acquire(). What is the recommended fix?

Raise max_size to match the number of tasks so none ever wait
Bound the fan-out with an asyncio.Semaphore so you never spawn more concurrent tasks than the pool can serve
Switch to a synchronous driver to serialize the work
Remove per-query timeouts so slow queries can finish

Your Progress

Answer Explanations