Append-Only Ledger Design in PostgreSQL Permalink to this section
↑ Part of Audit Trail and Compliance for Reconciliation.
A table with no UPDATE statements in the application code is not the same thing as an immutable ledger. Anyone with write access, a migration script, or a well-intentioned backfill can still issue UPDATE recon_ledger SET ... unless the database itself refuses the operation. This page builds that refusal in three enforced layers — revoked grants, a trigger that raises on any mutation attempt, and a SHA-256 hash chain that makes even a superuser’s out-of-band edit mathematically detectable — plus the partitioning and retention scheme that keeps the ledger operable at volume. It assumes the ledger already has a defined row schema; for the columns, retention classes, and evidentiary requirements that schema must satisfy, see Designing a SOX-Compliant Audit Log Schema.
Operational Trigger Signals Permalink to this section
Build the enforced version of this pattern — not just an application-level “we don’t update this table” convention — when any of the following hold:
- An auditor asks “prove no row was altered” and the honest answer today is “we don’t write code that does that.” A SOX walkthrough or external audit treats an unenforced convention as a control gap, not a control.
- A migration or backfill script has ever run
UPDATEagainst a ledger or history table. Even a one-time, well-reviewed correction sets a precedent that the table is mutable, and the next person to touch it will not know which edits were authorized. - The Matching & Reconciliation Algorithms engine or the Variance Write-Back and ERP Adjustment Posting flow posts financial adjustments that must be individually traceable — reversing a posted adjustment must be a new row, never an edit to the original.
- Storage or query latency on the ledger degrades past an acceptable threshold because years of rows sit in one unpartitioned table with no retention policy, forcing every audit query to scan history it does not need.
When two or more of these are true, the fix is not a code-review policy — it is database-enforced immutability with a verifiable tamper signal, built directly into the schema. This is the mechanism-level control that the rest of Audit Trail and Compliance for Reconciliation assumes is already in place before it asks about retention policy, access review, or evidence export.
Step-by-Step Implementation Permalink to this section
Immutability has to be layered because each layer defends against a different actor: application bugs, careless operators, and a database role with elevated privileges. Build it in this order.
- Define the append-only schema — a range-partitioned table keyed by
recorded_at, withprev_hashandrow_hashcolumns present from the first row. - Install the blocking trigger on the partitioned parent — a
BEFORE UPDATE OR DELETEtrigger that raises unconditionally; on PostgreSQL 11+, a trigger defined on a partitioned table automatically applies to every existing and future partition. - Revoke mutation grants from the writer role — belt-and-suspenders alongside the trigger, so a role misconfiguration alone cannot open a mutation path.
- Seed the genesis hash — the first row’s
prev_hashis a fixed 64-character sentinel, never a null or empty string, so the chain has one unambiguous starting point. - Compute the chained hash in the writer, not in SQL — canonicalize the payload, hash it with the prior row’s
row_hash, and serialize concurrent appends so two transactions never both read the same “last” hash. - Schedule partition rollover and retention — roll a new monthly partition ahead of need and detach expired partitions to write-once archival storage instead of deleting them.
-- 1. Append-only, partitioned schema. recorded_at drives both partitioning
-- and the global hash-chain ordering, so it must never be client-supplied.
CREATE TABLE recon_ledger (
ledger_id bigint GENERATED ALWAYS AS IDENTITY,
txn_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
prev_hash char(64) NOT NULL,
row_hash char(64) NOT NULL,
PRIMARY KEY (ledger_id, recorded_at)
) PARTITION BY RANGE (recorded_at);
CREATE TABLE recon_ledger_2026_07 PARTITION OF recon_ledger
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE recon_ledger_2026_08 PARTITION OF recon_ledger
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE INDEX recon_ledger_txn_idx ON recon_ledger (txn_id);
-- 2. Trigger function that unconditionally rejects UPDATE and DELETE.
-- Attached to the partitioned parent, it propagates to every partition.
CREATE OR REPLACE FUNCTION recon_ledger_block_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION
'recon_ledger is append-only: % on ledger_id % is not permitted',
TG_OP, OLD.ledger_id
USING ERRCODE = '23001';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER recon_ledger_no_update
BEFORE UPDATE ON recon_ledger
FOR EACH ROW EXECUTE FUNCTION recon_ledger_block_mutation();
CREATE TRIGGER recon_ledger_no_delete
BEFORE DELETE ON recon_ledger
FOR EACH ROW EXECUTE FUNCTION recon_ledger_block_mutation();
-- 3. Grants: the writer role can INSERT and SELECT only.
REVOKE UPDATE, DELETE, TRUNCATE ON recon_ledger FROM recon_writer;
GRANT INSERT, SELECT ON recon_ledger TO recon_writer;
The chained hash is the third layer: it does not prevent a mutation from a role that still has superuser or table-owner privileges, but it makes any such mutation mathematically detectable, because changing one row invalidates every row_hash that follows it. Each row’s hash is a function of the previous row’s hash and the current row’s canonical content:
where is the prior row’s row_hash, is concatenation, and is serialized deterministically so the same logical content always hashes to the same value. Compute this in the application layer, not in a SQL trigger, so the exact hashing logic is testable, versioned, and independent of database upgrades:
import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import psycopg
logger = logging.getLogger("recon.ledger")
GENESIS_HASH = "0" * 64
CHAIN_LOCK_KEY = "recon_ledger_chain" # advisory-lock namespace, one per ledger table
@dataclass(frozen=True)
class LedgerEntry:
"""One unit of work to append; never mutated once constructed."""
txn_id: str
event_type: str
payload: dict[str, Any]
def canonical_payload(payload: dict[str, Any]) -> str:
"""Deterministic JSON so identical logical content always hashes identically."""
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
def compute_row_hash(
prev_hash: str,
txn_id: str,
event_type: str,
payload: dict[str, Any],
recorded_at: datetime,
) -> str:
"""SHA-256 over the row's immutable fields, chained to the prior row's hash."""
digest_input = "|".join(
[prev_hash, txn_id, event_type, canonical_payload(payload), recorded_at.isoformat()]
)
return hashlib.sha256(digest_input.encode("utf-8")).hexdigest()
def fetch_last_hash(conn: psycopg.Connection) -> str:
"""Read the latest row_hash under a transaction-scoped advisory lock.
The lock serializes concurrent appenders so two writers never both read
the same "last" hash and fork the chain into two valid-looking branches.
"""
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (CHAIN_LOCK_KEY,))
cur.execute(
"SELECT row_hash FROM recon_ledger ORDER BY recorded_at DESC, ledger_id DESC LIMIT 1"
)
row = cur.fetchone()
return row[0] if row else GENESIS_HASH
def append_entry(conn: psycopg.Connection, entry: LedgerEntry) -> str:
"""Append one entry to the hash-chained ledger inside a single transaction."""
with conn.transaction():
prev_hash = fetch_last_hash(conn)
recorded_at = datetime.now(timezone.utc)
row_hash = compute_row_hash(
prev_hash, entry.txn_id, entry.event_type, entry.payload, recorded_at
)
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO recon_ledger
(txn_id, event_type, payload, recorded_at, prev_hash, row_hash)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
entry.txn_id,
entry.event_type,
json.dumps(entry.payload, default=str),
recorded_at,
prev_hash,
row_hash,
),
)
logger.info(
"ledger_append txn=%s event=%s prev=%s row=%s",
entry.txn_id, entry.event_type, prev_hash[:12], row_hash[:12],
)
return row_hash
The advisory lock is not optional. Without it, two concurrent transactions can both read the same prev_hash, each compute a valid-looking row_hash, and commit — producing two rows that both claim the same parent and silently forking the chain. pg_advisory_xact_lock scopes the lock to the transaction, so it releases automatically on commit or rollback even if the writer process crashes.
Configuration Reference Permalink to this section
Calibrate these per ledger table. The hash algorithm and lock key must stay fixed for the life of a chain — changing either mid-stream breaks verification for every row written before the change.
| Parameter | Accepted values | Default | Notes |
|---|---|---|---|
hash_algorithm |
sha256, sha512 |
sha256 |
Fixed for the table’s lifetime; never rotate without starting a new chain segment. |
partition_interval |
1 month, 1 week, 1 quarter |
1 month |
Monthly balances index size against partition-management overhead for typical reconciliation volume. |
retention_period |
3–10 years | 7 years | Aligned to SOX and the applicable financial-records retention statute, not a storage-cost decision. |
advisory_lock_key |
text, one per ledger table | recon_ledger_chain |
Passed through hashtext(); must be unique per chain to avoid cross-table lock contention. |
chain_verify_cadence |
hourly – daily | daily | Full or incremental recompute-and-compare pass, scheduled off-peak. |
archive_tier |
detach+glacier, detach+worm-bucket |
detach+worm-bucket |
Expired partitions are detached, never dropped, and moved to object storage with object-lock enabled. |
correction_event_type |
free text, closed set recommended | CORRECTION |
Marks a row that logically reverses an earlier one; the original row is never touched. |
Debugging & Recovery Permalink to this section
Immutability changes what “fixing a mistake” means: you can never repair a row, only add a new one and prove the chain is otherwise intact.
- Verifying the chain. Walk the table in
recorded_at, ledger_idorder across all partitions — a partition boundary carries no special meaning to the chain, so verification must query the parent table, not a single partition. Recomputecompute_row_hashfor each row using its storedprev_hash, and compare against the storedrow_hash. The first mismatch is the tamper point; every row after it is untrustworthy by construction, which is exactly the signal an auditor needs. - Confirming enforcement hasn’t drifted. After any migration, query
information_schema.role_table_grantsforrecon_writerandpg_triggerfor the two blocking triggers. A migration that recreates the table without reapplying the trigger and revokes is the most common way this control silently disappears. - Handling a legitimate correction. Never
UPDATEthe original row, even under emergency change control. Append a new row withevent_type = 'CORRECTION'whose payload references the originalledger_idand states the corrected value. The ledger then shows both the mistake and the fix, which is stronger evidence than a silently repaired number. - Recovering from an interrupted append. Because
append_entrywraps the read ofprev_hashand the insert in one transaction, a crash mid-append simply rolls back — no partial or orphaned row can enter the chain. If a dead connection appears to be holding the advisory lock, checkpg_locksjoined topg_stat_activity; a lock held by a session with no active backend is safe to wait out or terminate. - Backfilling historical data. A backfill of pre-ledger history is not an exception to append-only — insert it as ordinary rows with
recorded_atset to the true historical timestamps and let the chain absorb them in order. Never seed a backfill with a fabricatedprev_hash; compute it the same way the writer does, from the actual preceding row.
FAQ Permalink to this section
Does the hash chain replace the REVOKE and the trigger, or do I need all three? Permalink to this section
All three, because each defends a different actor. The REVOKE stops an application role with a bug or a compromised credential from issuing UPDATE/DELETE in the first place. The trigger is a second, independent gate that fires even if a grant is accidentally reintroduced by a future migration. The hash chain is the only layer that still protects you against a role with elevated privileges — a table owner or superuser can bypass grants and triggers, but cannot edit a row without invalidating every hash that follows it, which a routine verification pass will catch.
How do I handle a superuser who can bypass the trigger and the grants entirely? Permalink to this section
Assume that access exists and design for detection, not prevention, at that privilege tier. Restrict superuser and table-owner roles to a narrow break-glass process with logged, approved access; run chain_verify_cadence frequently enough that an out-of-band edit is caught within one audit cycle, not one year; and archive detached partitions to object storage with object-lock (WORM) enabled, so even a superuser edit inside PostgreSQL does not touch the archived copy used for external verification.
Why chain hashes at the row level instead of just storing a single table checksum? Permalink to this section
A single table-level checksum tells you that something changed, but not what or when — you would have to diff against a full prior backup to localize the tampering, if one even exists. Row-level chaining turns every row into a checkpoint: verification pinpoints the exact ledger_id where the chain breaks, and every row before it remains provably intact, which is the difference between “the ledger might be compromised” and “row 118,402 was altered after 2026-07-15T14:02:11Z.”
Related Permalink to this section
- Designing a SOX-Compliant Audit Log Schema — the row schema and retention classes this ledger design enforces
- Matching & Reconciliation Algorithms — the engine whose match and adjustment events populate the ledger
- Variance Write-Back and ERP Adjustment Posting — posted adjustments that must land as new, chained rows rather than edits
- Data Security Boundaries for Procurement Systems — the role and access controls the writer grant model depends on
- ↑ Parent: Audit Trail and Compliance for Reconciliation