Module 3: Audit Logs and History Tables
Module 3 deliverable: Audit Trail in TaskFlow (standalone)
What are you going to build and why?
You've reached the module's close. In capsules 02-07 you saw what to audit (capsule 02), three approaches in SQL (triggers, history tables, event sourcing) plus one in Python (an event listener), and the operational problem of retention and partitioning. Now comes the integration. You're going to build the complete pattern applied to a standalone tasks table: a PostgreSQL trigger that writes to audit.task_log, a FastAPI dependency that passes the user_id with SET LOCAL, monthly partitioning with automated partition creation, endpoints that consume the audit, and tests that verify the pattern works end-to-end.
The project is standalone: the tasks table exists on its own, with no RLS, no multi-tenancy, no cursor pagination from the guide. That's by design. Module 8 (the guide's capstone project) takes this same pattern and combines it with multi-tenancy (RLS from module 4), pagination (module 1), and the rest. Isolating the audit here lets you master it without the combined complexity.
You're going to come away with a GitHub repo that demonstrates the complete pattern: you can clone it, bring it up with docker compose up, try it with curl, verify the audit log with SQL queries, and show the result in an interview or a code review. It's portfolio-worthy in the sense that any senior dev recognizes the pattern on sight, and being able to defend it line by line is what separates "I read about auditing" from "I implemented auditing correctly."
Project objective
By the time you complete this project:
- You'll have implemented an audit log with PostgreSQL triggers over a real
taskstable, with all the module's decisions: filtering out excluded columns, generating the JSONB diff, capturing theuser_idviaSET LOCAL. - You'll have partitioned
audit.task_logby month from day 1, with an automated function that creates future partitions. - You'll have exposed FastAPI endpoints that demonstrate the pattern end-to-end: CRUD on tasks that generates audit entries, an "history of a task" endpoint that queries the audit log.
- You'll have written automated tests that verify: the
changed_byis captured correctly, the JSONB diff excludes sensitive columns, the inserts go to the right partition, the history endpoint returns correct data. - You'll have documented the decisions in
AUDIT-DECISIONS.mdandBENCHMARKS.md: why triggers, which columns to exclude, what retention policy, what measured overhead the trigger has.
How it fits with what you learned
| Module concept | Where it's used in the project |
|---|---|
| Capsule 02: what to audit | The decision about which columns to exclude (internal_notes, view_count); the documentation in AUDIT-DECISIONS.md |
| Capsule 03: PostgreSQL triggers | The audit.task_log_trigger function + the audit.diff_jsonb helper function + the integration with the FastAPI dependency |
| Capsule 04: history tables | NOT used in this project (the audit log is enough). The decision is documented. |
| Capsule 05: event sourcing | NOT used in this project (over-engineering for simple auditing). The decision is documented. |
| Capsule 06: SQLAlchemy listeners | NOT used in this project (self-hosted PostgreSQL allows triggers). Documentation of when to migrate. |
| Capsule 07: partitioning + retention | The monthly-partitioned schema, the create_partition_for_month function, the maintenance job |
Think of the project as the consolidated pattern: you applied triggers (capsule 03), filtered with judgment (capsule 02), partitioned from day 1 (capsule 07). Capsules 04, 05, and 06 gave you alternatives you documented but didn't use — because for this case (standalone TaskFlow, self-hosted PostgreSQL), triggers + partitioning are the right answer.
Technical specifications
Stack
- Language: Python 3.12+
- Main framework: FastAPI 0.110+
- ORM: SQLAlchemy 2.0+ (async)
- DB driver: asyncpg
- Database: PostgreSQL 16+ (needed for some partitioning features)
- Migrations: Alembic 1.13+
- Container: Docker + docker-compose for a reproducible environment
- Tests: pytest + pytest-asyncio + httpx for an async client
Initial setup
# Clone the scaffold repo
git clone https://github.com/<your-org>/taskflow-audit-module
cd taskflow-audit-module
# Bring up PostgreSQL 16
docker compose up -d postgres
# Set up the virtualenv and deps
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Apply the migrations
alembic upgrade head
# Verify the initial partitions got created
psql postgresql://postgres:postgres@localhost/taskflow \
-c "\dt+ audit.*"
# Bring up FastAPI
uvicorn app.main:app --reload
Required functionality
1. The base tasks schema with a partitioned audit log
Schema:
-- The domain table
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'closed', 'archived')),
priority INTEGER NOT NULL DEFAULT 0,
assignee_id BIGINT NULL,
internal_notes TEXT NULL, -- a NON-audited column (sensitive)
view_count BIGINT NOT NULL DEFAULT 0, -- a NON-audited column (noise)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- A trigger to auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tasks_update_updated_at
BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- The audit schema
CREATE SCHEMA IF NOT EXISTS audit;
-- The partitioned audit table
CREATE TABLE audit.task_log (
id BIGSERIAL,
entity_id BIGINT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
changed_by BIGINT NULL,
diff JSONB NOT NULL,
request_id UUID NULL,
source TEXT NULL CHECK (source IN ('api', 'cron', 'migration', 'manual') OR source IS NULL),
PRIMARY KEY (id, changed_at)
) PARTITION BY RANGE (changed_at);
-- The default partition (a safety net)
CREATE TABLE audit.task_log_default PARTITION OF audit.task_log DEFAULT;
-- Indexes on the parent table (they apply to the partitions)
CREATE INDEX idx_audit_task_log_entity_time
ON audit.task_log (entity_id, changed_at DESC);
CREATE INDEX idx_audit_task_log_changed_by_time
ON audit.task_log (changed_by, changed_at DESC)
WHERE changed_by IS NOT NULL;
CREATE INDEX idx_audit_task_log_diff_gin
ON audit.task_log USING GIN (diff jsonb_path_ops);
Expected behavior:
taskscan receive INSERT/UPDATE/DELETE normally.- Every operation fires the audit log trigger automatically.
- The inserts into
audit.task_loggo to the partition corresponding tochanged_at's month.
2. The diff helper function and the audit trigger
-- The helper function that generates the JSONB diff
CREATE OR REPLACE FUNCTION audit.diff_jsonb(
p_old JSONB,
p_new JSONB,
p_excluded_keys TEXT[] DEFAULT ARRAY[]::TEXT[]
)
RETURNS JSONB AS $$
DECLARE
v_diff JSONB := '{}'::JSONB;
v_key TEXT;
v_old_val JSONB;
v_new_val JSONB;
BEGIN
FOR v_key IN
SELECT jsonb_object_keys(p_old)
UNION
SELECT jsonb_object_keys(p_new)
LOOP
IF v_key = ANY(p_excluded_keys) THEN CONTINUE; END IF;
v_old_val := p_old -> v_key;
v_new_val := p_new -> v_key;
IF v_old_val IS DISTINCT FROM v_new_val THEN
v_diff := v_diff || jsonb_build_object(v_key, jsonb_build_array(v_old_val, v_new_val));
END IF;
END LOOP;
RETURN v_diff;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- The trigger function
CREATE OR REPLACE FUNCTION audit.task_log_trigger()
RETURNS TRIGGER AS $$
DECLARE
v_user_id BIGINT;
v_request_id UUID;
v_source TEXT;
v_diff JSONB;
v_excluded_keys TEXT[] := ARRAY[
'updated_at', 'created_at', 'internal_notes', 'view_count'
];
BEGIN
v_user_id := NULLIF(current_setting('audit.user_id', true), '')::BIGINT;
v_request_id := NULLIF(current_setting('audit.request_id', true), '')::UUID;
v_source := NULLIF(current_setting('audit.source', true), '');
CASE TG_OP
WHEN 'INSERT' THEN
v_diff := audit.diff_jsonb('{}'::jsonb, to_jsonb(NEW), v_excluded_keys);
WHEN 'DELETE' THEN
v_diff := audit.diff_jsonb(to_jsonb(OLD), '{}'::jsonb, v_excluded_keys);
WHEN 'UPDATE' THEN
v_diff := audit.diff_jsonb(to_jsonb(OLD), to_jsonb(NEW), v_excluded_keys);
END CASE;
IF TG_OP = 'UPDATE' AND v_diff = '{}'::jsonb THEN
RETURN NULL;
END IF;
INSERT INTO audit.task_log (entity_id, action, changed_by, request_id, source, diff)
VALUES (COALESCE(NEW.id, OLD.id), TG_OP, v_user_id, v_request_id, v_source, v_diff);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER task_log_trigger
AFTER INSERT OR UPDATE OR DELETE ON tasks
FOR EACH ROW
EXECUTE FUNCTION audit.task_log_trigger();
3. The automatic partition creation function
CREATE OR REPLACE FUNCTION audit.create_partition_for_month(
p_year INTEGER,
p_month INTEGER
)
RETURNS TEXT AS $$
DECLARE
v_partition_name TEXT;
v_start_date DATE;
v_end_date DATE;
BEGIN
v_partition_name := format('audit.task_log_y%sm%s',
p_year::TEXT, lpad(p_month::TEXT, 2, '0'));
v_start_date := make_date(p_year, p_month, 1);
v_end_date := v_start_date + INTERVAL '1 month';
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %s PARTITION OF audit.task_log FOR VALUES FROM (%L) TO (%L)',
v_partition_name, v_start_date, v_end_date
);
RETURN v_partition_name;
END;
$$ LANGUAGE plpgsql;
And a script that creates the initial partitions:
# scripts/create_initial_partitions.py
import asyncio
from datetime import date
from sqlalchemy import text
from app.db import AsyncSessionLocal
async def main():
today = date.today()
async with AsyncSessionLocal() as session:
# Create the partition for last month, the current one, and the next 3
for offset in range(-1, 4):
year = today.year
month = today.month + offset
while month > 12: month -= 12; year += 1
while month < 1: month += 12; year -= 1
result = await session.execute(text(
"SELECT audit.create_partition_for_month(:y, :m)"
), {"y": year, "m": month})
print(f"Created: {result.scalar()}")
await session.commit()
if __name__ == "__main__":
asyncio.run(main())
4. The FastAPI dependency for the audit context
# app/audit_context.py
from typing import Optional
from uuid import UUID, uuid4
from fastapi import Depends, Header, Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
async def audit_context(
request: Request,
db: AsyncSession = Depends(get_session),
x_user_id: Optional[int] = Header(default=None),
x_request_id: Optional[str] = Header(default=None),
) -> AsyncSession:
"""
Sets the audit context on the transaction.
In production, x_user_id would be extracted from the JWT, not from a header.
For this demo project, we simplify.
"""
if x_user_id is not None:
await db.execute(text("SET LOCAL audit.user_id = :uid"), {"uid": str(x_user_id)})
request_id = x_request_id or str(uuid4())
await db.execute(text("SET LOCAL audit.request_id = :rid"), {"rid": request_id})
await db.execute(text("SET LOCAL audit.source = 'api'"))
return db
5. CRUD endpoints + a history endpoint
# app/main.py
from typing import Optional, List
from fastapi import Depends, FastAPI, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit_context import audit_context
from app.models import Task
app = FastAPI(title="TaskFlow Audit Module")
class TaskCreate(BaseModel):
title: str
description: Optional[str] = None
priority: int = 0
assignee_id: Optional[int] = None
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[int] = None
assignee_id: Optional[int] = None
class TaskOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
description: Optional[str]
status: str
priority: int
assignee_id: Optional[int]
@app.post("/tasks", status_code=status.HTTP_201_CREATED, response_model=TaskOut)
async def create_task(payload: TaskCreate, db: AsyncSession = Depends(audit_context)):
task = Task(**payload.model_dump())
db.add(task)
await db.commit()
await db.refresh(task)
return task
@app.get("/tasks/{task_id}", response_model=TaskOut)
async def get_task(task_id: int, db: AsyncSession = Depends(audit_context)):
task = await db.get(Task, task_id)
if task is None:
raise HTTPException(404, "Task not found")
return task
@app.put("/tasks/{task_id}", response_model=TaskOut)
async def update_task(
task_id: int,
payload: TaskUpdate,
db: AsyncSession = Depends(audit_context),
):
task = await db.get(Task, task_id)
if task is None:
raise HTTPException(404, "Task not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(task, key, value)
await db.commit()
await db.refresh(task)
return task
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(task_id: int, db: AsyncSession = Depends(audit_context)):
task = await db.get(Task, task_id)
if task is None:
raise HTTPException(404, "Task not found")
await db.delete(task)
await db.commit()
@app.get("/tasks/{task_id}/history")
async def task_history(
task_id: int,
limit: int = 100,
db: AsyncSession = Depends(audit_context),
):
"""
Returns a task's change history.
Filters by changed_at over the last 12 months to take advantage of partition pruning.
"""
result = await db.execute(text("""
SELECT
changed_at, action, changed_by, source, diff
FROM audit.task_log
WHERE entity_id = :tid
AND changed_at >= NOW() - INTERVAL '12 months'
ORDER BY changed_at DESC
LIMIT :limit
"""), {"tid": task_id, "limit": limit})
return [
{
"changed_at": row.changed_at.isoformat(),
"action": row.action,
"changed_by": row.changed_by,
"source": row.source,
"changes": [
{"field": k, "old": v[0], "new": v[1]}
for k, v in row.diff.items()
],
}
for row in result
]
6. Automated tests
# tests/test_audit.py
import pytest
from sqlalchemy import text
pytestmark = pytest.mark.asyncio
async def test_insert_genera_audit_con_changed_by(client):
res = await client.post(
"/tasks",
json={"title": "T1", "priority": 1},
headers={"X-User-Id": "47"},
)
assert res.status_code == 201
task_id = res.json()["id"]
# Check the audit
history = await client.get(f"/tasks/{task_id}/history")
entries = history.json()
assert len(entries) == 1
assert entries[0]["action"] == "INSERT"
assert entries[0]["changed_by"] == 47
titles_in_diff = [c for c in entries[0]["changes"] if c["field"] == "title"]
assert len(titles_in_diff) == 1
assert titles_in_diff[0]["new"] == "T1"
async def test_update_genera_audit_solo_de_cambios(client):
# Create
res = await client.post(
"/tasks", json={"title": "T1"}, headers={"X-User-Id": "47"}
)
task_id = res.json()["id"]
# Update only the title
await client.put(
f"/tasks/{task_id}",
json={"title": "T1 modified"},
headers={"X-User-Id": "23"},
)
history = await client.get(f"/tasks/{task_id}/history")
entries = history.json()
update_entries = [e for e in entries if e["action"] == "UPDATE"]
assert len(update_entries) == 1
assert update_entries[0]["changed_by"] == 23
# Only the title changed, no other columns
fields_changed = [c["field"] for c in update_entries[0]["changes"]]
assert "title" in fields_changed
assert "priority" not in fields_changed
assert "status" not in fields_changed
async def test_internal_notes_no_se_auditan(client, session):
# Create with internal_notes
await session.execute(text("SET LOCAL audit.user_id = '47'"))
res = await session.execute(text("""
INSERT INTO tasks (title, internal_notes)
VALUES ('T1', 'Private admin note')
RETURNING id
"""))
task_id = res.scalar()
await session.commit()
# Verify the audit doesn't contain internal_notes
result = await session.execute(text("""
SELECT diff FROM audit.task_log WHERE entity_id = :tid
"""), {"tid": task_id})
diff = result.scalar()
assert "internal_notes" not in diff, "internal_notes leaked into the audit log"
async def test_view_count_no_se_audita(client, session):
res = await client.post("/tasks", json={"title": "T1"}, headers={"X-User-Id": "47"})
task_id = res.json()["id"]
# UPDATE only view_count (it shouldn't get audited)
await session.execute(text("""
UPDATE tasks SET view_count = view_count + 1 WHERE id = :tid
"""), {"tid": task_id})
await session.commit()
# Verify: only the initial INSERT is there, there's no UPDATE in the audit
result = await session.execute(text("""
SELECT COUNT(*) FROM audit.task_log WHERE entity_id = :tid AND action = 'UPDATE'
"""), {"tid": task_id})
assert result.scalar() == 0, "A view_count UPDATE must not be audited"
async def test_delete_genera_audit_con_diff_completo(client):
res = await client.post(
"/tasks", json={"title": "T1", "priority": 5}, headers={"X-User-Id": "47"}
)
task_id = res.json()["id"]
await client.delete(f"/tasks/{task_id}", headers={"X-User-Id": "23"})
history = await client.get(f"/tasks/{task_id}/history")
entries = history.json()
delete_entries = [e for e in entries if e["action"] == "DELETE"]
assert len(delete_entries) == 1
assert delete_entries[0]["changed_by"] == 23
# On DELETE, the diff has [value, null] for each column
title_change = next(c for c in delete_entries[0]["changes"] if c["field"] == "title")
assert title_change["old"] == "T1"
assert title_change["new"] is None
async def test_inserciones_van_a_particion_correcta(session):
"""Verifies the partitioning works: rows inserted today go to this month's partition."""
from datetime import date
today = date.today()
expected_partition = f"task_log_y{today.year}m{today.month:02d}"
# Insert
res = await session.execute(text("""
INSERT INTO tasks (title) VALUES ('Test partition') RETURNING id
"""))
task_id = res.scalar()
await session.commit()
# Verify the audit log went to the right partition
result = await session.execute(text(f"""
SELECT COUNT(*) FROM audit.{expected_partition}
WHERE entity_id = :tid
"""), {"tid": task_id})
assert result.scalar() >= 1, f"The audit didn't go to partition {expected_partition}"
async def test_endpoint_history_devuelve_orden_correcto(client):
res = await client.post("/tasks", json={"title": "v1"}, headers={"X-User-Id": "47"})
task_id = res.json()["id"]
await client.put(
f"/tasks/{task_id}", json={"title": "v2"}, headers={"X-User-Id": "47"}
)
await client.put(
f"/tasks/{task_id}", json={"title": "v3"}, headers={"X-User-Id": "47"}
)
history = await client.get(f"/tasks/{task_id}/history")
entries = history.json()
# Most recent first
assert len(entries) == 3
assert entries[0]["action"] == "UPDATE"
assert entries[-1]["action"] == "INSERT"
Validation and error handling
What has to be validated
-
titlecan't be empty on CREATE. -
statusonly accepts values from the CHECK constraint (open,in_progress,closed,archived). -
priorityhas to be an integer (Pydantic validates it). - The
X-User-Idheader is optional but if it's present it has to be an integer.
Errors that have to be handled
- Task not found:
GET/PUT/DELETE /tasks/{id}with a nonexistent id → HTTP 404. - Invalid status:
PUT /tasks/{id}withstatus="foo"→ HTTP 422 (Pydantic). - Internal server error: if the audit trigger fails for some reason (e.g. a full default partition and a constraint), HTTP 500. The whole transaction rolls back (audit + change). Log the error.
Minimal implementation example
This is the project's skeleton. You extend it to cover all the required functionality. It isn't the complete solution.
taskflow-audit-module/
├── app/
│ ├── __init__.py
│ ├── db.py # engine + AsyncSession
│ ├── models.py # Task model
│ ├── audit_context.py # the dependency with SET LOCAL
│ └── main.py # FastAPI app + endpoints
├── alembic/
│ ├── env.py
│ └── versions/
│ └── 001_initial_schema.py
├── scripts/
│ ├── create_initial_partitions.py
│ └── verify_audit.py
├── jobs/
│ └── maintain_partitions.py # a monthly cron
├── tests/
│ ├── conftest.py # fixtures: session, client
│ └── test_audit.py
├── docker-compose.yml # postgres 16
├── Dockerfile
├── requirements.txt
├── README.md
├── AUDIT-DECISIONS.md # documentation of the what-to-audit decisions
└── BENCHMARKS.md # overhead measurements
# app/main.py — minimal skeleton
from fastapi import FastAPI
app = FastAPI(title="TaskFlow Audit Module")
@app.get("/health")
async def health():
return {"status": "ok"}
# TODO: implement the CRUD endpoints + history (see "Required functionality")
# docker-compose.yml — minimal skeleton
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: taskflow
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
This skeleton:
- ✅ Is runnable (you can run
docker compose upanduvicorn). - ✅ Shows the expected repo structure.
- ❌ Does NOT include the required functionality (that's your job).
Evaluation rubric (self-check)
Functionality (50 points)
- (10 pts) The PostgreSQL trigger implemented correctly. It captures INSERT/UPDATE/DELETE, writes to
audit.task_logwith a JSONB diff. - (10 pts)
SET LOCAL audit.user_idintegrated. Thechanged_byin the log reflects the HTTP request'sX-User-Id. - (10 pts) Monthly partitioning implemented. The
audit.task_logtable partitioned, thecreate_partition_for_monthfunction, the default partition present. - (5 pts) A correct
audit.diff_jsonbfunction. It generates the diff in the{"col": [old, new]}format, and excludes keys from theexcluded_keysarray. - (5 pts) The CRUD endpoints working. POST/GET/PUT/DELETE on
/tasks. - (5 pts) The
/tasks/{id}/historyendpoint working. It returns the history with parsed changes, ordered by date desc. - (5 pts) The excluded columns (
internal_notes,view_count,created_at,updated_at) do NOT appear in the log.
Tests (20 points)
- (5 pts) A test verifies the
changed_byis captured correctly. - (5 pts) A test verifies the excluded columns don't show up in the log.
- (5 pts) A test verifies an UPDATE of an excluded column doesn't generate an audit entry.
- (5 pts) A test verifies the inserts go to the current month's correct partition.
Code quality (15 points)
- (5 pts) Consistent type hints throughout the Python code.
- (5 pts) Migrations with Alembic, not loose SQL scripts.
- (5 pts) A clear project structure (separating the model, the context, the endpoints).
Documentation (15 points)
- (5 pts)
README.mdwith clear setup and usage instructions. - (5 pts)
AUDIT-DECISIONS.mddocuments which columns are audited and which aren't, with reasons. - (5 pts)
BENCHMARKS.mddocuments the trigger's measured overhead (INSERT with vs without the trigger).
Extra credit (optional, up to +10 pts)
- (+3 pts) A runnable
jobs/maintain_partitions.pyjob that creates future partitions. - (+3 pts) Integration with a logging system (Loki, CloudWatch, simple structured stdout) that correlates the audit's
request_idwith the app's logs. - (+4 pts) An implementation of the archive to S3 (
localstackfor development, real S3 for production) with post-upload verification.
Total: 100 points Pass: ≥70 points. Exemplary: ≥90 points + extras.
Common mistakes in this project
Mistake 1: changed_by is always NULL
Why it happens: the audit_context dependency isn't being applied, or the SET LOCAL doesn't run before the INSERT/UPDATE.
How to fix it: verify the endpoint uses Depends(audit_context) (not Depends(get_session)). Verify the text("SET LOCAL...") runs in the dependency. A test that fails:
async def test_changed_by_se_captura(client):
res = await client.post("/tasks", json={"title": "T1"}, headers={"X-User-Id": "47"})
history = await client.get(f"/tasks/{res.json()['id']}/history")
assert history.json()[0]["changed_by"] == 47
Mistake 2: the inserts go to the default partition
Why it happens: the partitions for the current month aren't created. PostgreSQL puts the inserts in the default.
How to fix it: run python scripts/create_initial_partitions.py before trying the app. Verify:
SELECT COUNT(*) FROM audit.task_log_default;
-- It should be 0 in normal operation
Mistake 3: the trigger fires twice (duplicate audit)
Why it happens: the migration ran twice, or uvicorn's hot-reload re-runs init scripts.
How to fix it: make sure the trigger is created with CREATE OR REPLACE TRIGGER or that it gets cleanly DROPped+CREATEd. Verify:
SELECT tgname FROM pg_trigger WHERE tgrelid = 'tasks'::regclass;
-- There should be only one audit trigger (plus the updated_at one)
Mistake 4: the /tasks/{id}/history endpoint returns an unparsed diff
Why it happens: the code returns the JSONB without transforming it into the {"field": ..., "old": ..., "new": ...} format.
How to fix it: use the module example's transformation:
"changes": [
{"field": k, "old": v[0], "new": v[1]}
for k, v in row.diff.items()
],
Mistake 5: intermittent tests from contamination between sessions
Why it happens: the tests don't isolate the sessions correctly, or the SET LOCAL "sticks" between tests.
How to fix it: make sure each test gets a fresh session via a fixture, and that the transaction rolls back at the end of the test:
@pytest_asyncio.fixture
async def session():
async with AsyncSessionLocal() as s:
yield s
await s.rollback()
Mistake 6: forgetting to disable the trigger in bulk operations
Why it happens: a migration or seed script inserts thousands of rows, each one fires the trigger, the audit log explodes.
How to fix it: for bulk inserts where the audit is optional, disable it temporarily:
async def bulk_seed(session, count=10000):
await session.execute(text("ALTER TABLE tasks DISABLE TRIGGER task_log_trigger"))
# ... bulk insert
await session.execute(text("ALTER TABLE tasks ENABLE TRIGGER task_log_trigger"))
Document the bypass in AUDIT-DECISIONS.md.
What to do if you get stuck
- The trigger doesn't fire: verify with
\dft+ audit.*in psql that the function exists, and\d+ tasksthat the trigger is active. - Partitioning throws an error on INSERT: verify a partition exists for the current month with
\dt audit.task_log_*. - Tests fail with "schema audit does not exist": the migration wasn't applied. Run
alembic upgrade head. - The diff's JSONB is enormous: review
v_excluded_keysin the trigger, make surecreated_at,updated_at,internal_notes,view_countare excluded. - The history endpoint is slow: verify the
idx_audit_task_log_entity_timeindex exists. EXPLAIN the endpoint's query.
Resources for the project
- PostgreSQL Documentation — PL/pgSQL Trigger Functions — the reference for the trigger.
- PostgreSQL Documentation — Table Partitioning — the reference for partitioning.
- SQLAlchemy 2.0 — Async — the reference for the async setup.
- FastAPI — Dependencies — the reference for understanding
Depends(audit_context). - Alembic — Tutorial — the reference for writing the migrations.
- pytest-asyncio — Documentation — the reference for async tests with fixtures.
- The module's capsules: all of them (capsules 02-07) are reference. If you get stuck on a specific decision, review the corresponding capsule.
What comes next
This project closes module 3 (defensive modeling). What you built gets extended in module 4 and eventually in the capstone project of module 8:
In module 4 (Multi-Tenancy with RLS): you're going to learn to isolate tenants in PostgreSQL. The audit log you built gets enriched with tenant_id: every row of audit.task_log also records which tenant generated the change. The integration is elegant: on top of SET LOCAL audit.user_id, you're going to set SET LOCAL audit.tenant_id. The trigger reads both. RLS also simplifies the audit log's queries — you automatically only see your tenant's logs.
In module 8 (the complete TaskFlow API): this audit pattern gets combined with all the module's others. Cursor pagination on /tasks, soft delete from module 2, multi-tenant RLS from module 4, optimistic locking from module 6, bulk import from module 7. The audit log captures every change — the pattern you built here scales with the added complexity.
Before moving on, make sure your project:
- ✅ Passes the automated tests (
pytest). - ✅ The
/tasks/{id}/historyendpoint returns a clean, ordered audit. - ✅ The excluded columns (
internal_notes,view_count) don't appear in any audit entry. - ✅ The current month's partitions exist and receive the inserts (verify with
SELECT COUNT(*) FROM audit.task_log_yYYYYmMM). - ✅
AUDIT-DECISIONS.mddocuments the decisions made. - ✅
BENCHMARKS.mdshows the trigger's overhead (INSERT with the trigger vs without).
If all that works, you're capable of implementing an audit log with PostgreSQL triggers in real production. Module 4 awaits.
Module 3 — SQL Patterns for Production APIs Guide
Next module: Multi-Tenancy in PostgreSQL — RLS as the isolation mechanism between tenants.