Postgres as Institutional Memory: Schemas, Audit, and Boring Reliability

Why your database should outlive every microservice

by
Postgres as Institutional Memory: Schemas, Audit, and Boring Reliability

Postgres as Institutional Memory: Schemas, Audit, and Boring Reliability

Every startup I've worked at has a graveyard of microservices. Each one held a piece of the company's truth — user preferences, billing history, feature flags — until the service was rewritten, the team disbanded, or the CTO declared a "cloud migration." Data got lost, migrated poorly, or simply left behind in a database dump nobody ever reads.

This is not a technology problem. It's a memory problem.

Institutional memory is the ability of an organization to recall what happened, why it happened, and how to reproduce it. Most teams treat this as an afterthought, relying on Slack logs, Notion docs, and the hippocampus of the senior engineer who quit last month.

There is a better way: Postgres as the single source of truth for everything that matters.

Not Postgres as a transactional OLTP database. Postgres as the control plane for your entire system's memory. Schemas enforce structure. Audit tables capture every change. And boring reliability means your data outlives any single service, deployment, or team.

Why Postgres, and not something shiny

Every year a new database promises to solve all your problems. Document stores, graph databases, event streams, blockchain-inspired ledgers. They all have their place, but none of them have the staying power of Postgres.

Postgres has been around since 1996. It's battle-tested, well-understood, and has a migration path that doesn't require a forklift upgrade every two years. Its extension ecosystem (pgvector, postgis, pgcrypto, pg_stat_statements) lets you add capabilities without changing your storage layer. And its schema system — CREATE SCHEMA, CREATE TABLE, CREATE VIEW — is the closest thing to a formal data contract that exists in open source.

When I say "institutional memory," I mean the database should be able to answer:

  • What was the state of this entity on January 15, 2023?
  • Who changed this row, and what was the old value?
  • What queries have been run against this table in the last 30 days?
  • Can I replay the entire history of this workflow?

Postgres can do all of this with built-in features and a few well-designed schemas. No event sourcing framework required.

Schema as contract

Your database schema is not an implementation detail. It is the canonical model of your business domain. Every service, every API, every frontend is a transient interpretation of that model.

A well-designed schema uses:

  • DOMAIN types to enforce business rules at the column level. For example, CREATE DOMAIN email AS TEXT CHECK (VALUE ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') prevents bad data from ever entering the database, regardless of which service inserts it.
  • NOT NULL and CHECK constraints to encode invariants. If an order must have a non-empty customer ID and a positive total, the schema should enforce that — not the application code.
  • Foreign keys to maintain referential integrity. If you delete a user, should their orders cascade? Or be set to NULL? The schema makes that explicit.
  • ENUM types for status fields. Avoid magic strings. CREATE TYPE order_status AS ENUM ('pending', 'confirmed', 'shipped', 'cancelled') makes the possible states obvious to anyone reading the schema.

But the real power is in schema versioning. Every migration is a commit to your institutional memory. Use pg_migrate or plain SQL files in a migrations/ directory, each with a timestamp and a description. Never modify a migration that has already been applied. If you need to change the past, write a new migration that alters the schema forward.

This gives you a complete history of how your data model evolved. Six months from now, you can look at migration 2024-06-01_add_currency_to_orders.sql and understand why the currency column was added — because the business started accepting EUR.

Audit: the memory layer

Schema tells you what the data should look like. Audit tells you what happened.

Postgres offers two main approaches to auditing:

  1. hstore or jsonb audit tables — a single table that records every INSERT, UPDATE, and DELETE on selected tables, with the old and new row values.
  2. pgAudit — an extension that logs SQL statements to the Postgres log, which can be ingested by a log aggregator.

For institutional memory, I prefer the first approach. The data stays in Postgres, queryable with standard SQL. You don't need a separate ELK stack to answer "who changed this user's email last week?"

Here's a minimal audit table:

CREATE TABLE audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name TEXT NOT NULL,
    operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
    old_data JSONB,
    new_data JSONB,
    changed_by TEXT NOT NULL DEFAULT current_user,
    changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_audit_table_name ON audit_log(table_name);
CREATE INDEX idx_audit_changed_at ON audit_log(changed_at);

Then create a trigger function that populates it:

CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO audit_log(table_name, operation, new_data, changed_by)
        VALUES (TG_TABLE_NAME, 'INSERT', row_to_json(NEW), current_user);
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO audit_log(table_name, operation, old_data, new_data, changed_by)
        VALUES (TG_TABLE_NAME, 'UPDATE', row_to_json(OLD), row_to_json(NEW), current_user);
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO audit_log(table_name, operation, old_data, changed_by)
        VALUES (TG_TABLE_NAME, 'DELETE', row_to_json(OLD), current_user);
        RETURN OLD;
    END IF;
END;
$$ LANGUAGE plpgsql;

Attach it to any table you want to audit:

CREATE TRIGGER users_audit
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_trigger();

Now you can query: "Show me all changes to user 42's email in the last 30 days":

SELECT changed_at, old_data->>'email' AS old_email, new_data->>'email' AS new_email, changed_by
FROM audit_log
WHERE table_name = 'users'
  AND (old_data->>'id' = '42' OR new_data->>'id' = '42')
  AND changed_at > now() - INTERVAL '30 days'
ORDER BY changed_at DESC;

This is your institutional memory. It's queryable, it's complete, and it lives in the same database as your operational data. No separate system to maintain, no export/import pipelines, no schema drift.

Boring reliability

The third pillar is the most overlooked: boring reliability. Postgres is not exciting. It doesn't have a fancy distributed consensus algorithm. It doesn't auto-scale to infinity. But it does one thing extremely well: it stores data durably and consistently.

To make Postgres your institutional memory, you need to treat it with the respect it deserves:

  • Backups: Use pg_dump for logical backups and pg_basebackup for physical. Store them off-site, encrypted. Test restoration quarterly. I've seen too many teams discover their backups are corrupt when they actually need them.
  • Point-in-time recovery (PITR): Enable WAL archiving. With archive_mode = on and archive_command, you can replay to any second in time. This is the ultimate safety net.
  • Connection pooling: Use pgbouncer (version 1.21+) to handle thousands of connections without overwhelming the database. Transaction pooling is fine for most workloads; session pooling if you use advisory locks or LISTEN/NOTIFY.
  • Monitoring: Track pg_stat_activity, pg_stat_statements, and replication lag. Set up alerts for long-running queries, deadlocks, and disk space. Use pgBadger for log analysis.
  • Replication: Use streaming replication for high availability. Keep a hot standby that can be promoted in seconds. Read replicas for analytics queries that would otherwise compete with production traffic.

None of this is new. That's the point. The boring stuff is what ensures your institutional memory survives a server crash, a cloud region outage, or a ransomware attack.

Case study: Replacing a SaaS audit trail

I once worked with a company that used a popular SaaS audit logging service. It cost $15,000/year and stored 90 days of history. The CEO wanted to keep data for 7 years (EU AI Act compliance). The SaaS vendor quoted $120,000/year.

We built the audit table above in Postgres, added a partitioning scheme by month, and set up a cron job to archive partitions older than 2 years to cold storage (S3 Glacier). Total cost: ~$200/month in Postgres storage and a small EC2 instance for the archiver. Retention: forever, with instant query access for the last 2 years and 24-hour retrieval for older data.

The audit data is now part of the same database as the operational data. Queries that used to join across systems ("Show me the user's profile and their audit history") became a simple SQL join. The engineering team gained the ability to answer compliance questions in minutes instead of days.

Schema design patterns for memory

Beyond audit, there are schema patterns that directly encode institutional knowledge:

Event-sourced state

Instead of storing only the current state, store the events that led to it. This is the heart of event sourcing, but you don't need a framework:

CREATE TABLE order_events (
    id BIGSERIAL PRIMARY KEY,
    order_id UUID NOT NULL REFERENCES orders(id),
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_order_events_order_id ON order_events(order_id);

The current state of an order is just a materialized view or a query that replays the events. This gives you a complete history: you can see every status change, every address update, every cancellation reason.

Temporal tables

Postgres doesn't have built-in temporal tables (like SQL:2011), but you can implement them with triggers and a history table. For each table, maintain a _history table that records the previous version whenever a row is updated. This is like audit, but structured so you can query "what was the state of this row at time T?"

CREATE TABLE users_history (
    id BIGSERIAL PRIMARY KEY,
    user_id UUID NOT NULL,
    email TEXT,
    name TEXT,
    valid_from TIMESTAMPTZ NOT NULL,
    valid_to TIMESTAMPTZ NOT NULL DEFAULT 'infinity'
);

-- On update, insert old row into history with valid_to = now()

Schema documentation

Use COMMENT ON to document every table, column, and constraint. This metadata is queryable from information_schema and can be exported to generate living documentation:

COMMENT ON TABLE users IS 'Core user accounts. Soft-deleted users have deleted_at set.';
COMMENT ON COLUMN users.email IS 'Verified email address, unique across all users.';
COMMENT ON CONSTRAINT users_email_unique ON users IS 'Enforced at DB level to prevent duplicates from race conditions.';

This is documentation that never goes stale because it lives next to the schema itself.

The boring truth

Institutional memory is not about choosing the right database. It's about choosing a database that will still be around in 10 years, and designing it to remember everything that matters.

Postgres is that database. It's boring, it's reliable, and it's capable of far more than most teams give it credit for. With a few schema patterns and a commitment to audit, you can turn your Postgres instance into the single source of truth that survives every service rewrite, every team reorganization, and every new CTO's grand vision.

Your future self — and the junior developer who has to debug a production issue at 2 AM — will thank you.

#audit#data-sovereignty#memory#postgres#schema
Share — X / Twitter · LinkedIn · HN · Email
Damir Radulić
Founder of RiNET. On the Croatian internet since 1996 (Kvarner Net). In Amsterdam now, building autonomous AI infrastructure that runs on Monday morning when nobody's watching — sovereign stacks, agent swarms, LoRA fine-tuning, civic-intelligence platforms.