You need a durable trail of who changed which rows in a set of business tables. Compare writing audit rows from AFTER-row triggers against building the trail from the database's transaction log with change data capture, and say when each is the right choice.
answer
- Trigger = in-transaction, doubles writes, sees the app user
- CDC = off the write path, committed changes only, weak actor
- Neither sees SELECT
- TRUNCATE / bulk load / disabled trigger = blind spots
- Stalled CDC reader pins log segments and fills the disk
basics
~20 sTriggers write history inside the transaction: atomic, can capture the app user from session context, but they double writes, must be maintained per table, miss reads and bulk paths, and vanish on rollback. Log-based capture is off the hot path and catches every committed change, but records only committed data changes with weak actor attribution.
solid answer
~60 s**Triggers**: an AFTER INSERT/UPDATE/DELETE row trigger writes old and new images into a history table in the same transaction. Strengths — atomic with the change, no extra infrastructure, and it can read a session context variable to record the *application* user rather than the pooled login. Weaknesses — write amplification (roughly double the writes and transaction-log traffic), latency added to every hot-path statement, per-table code that drifts as columns are added, and blind spots: TRUNCATE, bulk loaders that skip triggers, replication apply, and anything a superuser does after disabling triggers. Rolled-back changes leave no record. **Log-based CDC**: a reader decodes the transaction log (WAL/binlog/redo) and streams committed row changes off-box. Strengths — negligible cost on the write path, complete for committed data changes including bulk operations, totally ordered, and lands outside the database where it is harder to tamper with. Weaknesses — asynchronous, actor attribution is at best the database login, schema evolution needs handling, and a stalled consumer pins log segments and can fill the disk. Neither sees SELECTs. Common answer: CDC for data lineage, engine auditing for access and DDL, triggers only where synchronous app-user attribution on a few tables is genuinely required.
code
sql · 32 linesCREATE TABLE accounts_history (
history_id bigserial PRIMARY KEY,
account_id bigint NOT NULL,
action char(1) NOT NULL,
old_row jsonb,
new_row jsonb,
db_login text NOT NULL,
app_user_id text,
txid bigint NOT NULL,
changed_at timestamptz NOT NULL
);
CREATE FUNCTION audit_accounts() RETURNS trigger AS $$
BEGIN
INSERT INTO accounts_history
(account_id, action, old_row, new_row, db_login, app_user_id, txid, changed_at)
VALUES (
COALESCE(NEW.id, OLD.id),
LEFT(TG_OP, 1),
CASE WHEN TG_OP <> 'INSERT' THEN to_jsonb(OLD) END,
CASE WHEN TG_OP <> 'DELETE' THEN to_jsonb(NEW) END,
CURRENT_USER,
current_setting('app.user_id', true),
txid_current(),
now()
);
RETURN NULL;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER accounts_audit
AFTER INSERT OR UPDATE OR DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION audit_accounts();go deeper
Say what each mechanism is, that triggers run inside the transaction and CDC reads the transaction log, and that neither records reads.
Contrast write amplification against off-path cost, name the trigger blind spots (TRUNCATE, bulk load, disabled triggers) and CDC's coarse actor attribution.
Own the operational failure modes — history-table growth and partition purging, schema drift, CDC slot retention filling the disk — and describe how to carry end-user identity through CDC in-band.
Frame it as three tools for three jobs (lineage, access evidence, synchronous attribution), and set the policy for which tables justify in-transaction overhead versus a shared off-box pipeline.
## The two mechanisms **Trigger-based audit trails.** For each table you want history on, you attach an AFTER INSERT/UPDATE/DELETE row-level trigger that inserts a record into a companion history table — typically the old row image, the new row image, the action, the timestamp, the database login, and (importantly) whatever end-user identity the application stashed in the session. The audit row is written in the same transaction as the change. **Log-based change data capture.** Every relational engine already writes a durable, ordered record of committed changes for crash recovery and replication (the write-ahead log, binlog, or redo). A CDC reader attaches as if it were a replica, decodes those records into logical row changes, and streams them to a collector, topic, or table outside the database. ## Where the cost lands Triggers put the cost **in the transaction**. Every audited write becomes at least two writes: the base row and the history row, each of which also generates transaction-log traffic, index maintenance and, over time, table bloat and vacuum/purge work. On a hot table this is a measurable latency tax on the user-facing path, and it is paid by the same connections serving requests. Bulk operations feel it worst: a million-row update becomes two million row writes plus a million trigger invocations. CDC puts the cost **outside the transaction**. The log is being written anyway; the reader is another consumer of it. The write path pays nothing beyond keeping the relevant log segments around long enough for the reader to consume them — which is exactly where CDC's operational risk lives (below). ## Coverage: what each mechanism can and cannot see Triggers see only what fires them. The standard blind spots: - **TRUNCATE** does not fire row triggers (a statement-level trigger is needed, and it has no row images). - **Bulk loaders and direct-path/copy utilities** may bypass triggers by design. - **Replication apply** on a replica may not fire triggers unless explicitly enabled, so history diverges between primary and replica. - **A privileged user can disable the trigger, make the change, and re-enable it** — leaving a clean history table. Unless the ALTER that disabled it was itself audited by the engine, the trail lies. - **Rolled-back transactions leave nothing**, because the audit row rolls back with the change. For data lineage that is correct; for detecting attempted tampering it is a gap. CDC sees every **committed** change to data, including bulk operations and anything a superuser does to table contents, because the recovery log cannot be bypassed without corrupting the database. But it does not see uncommitted or rolled-back work, and — depending on engine and configuration — DDL is often visible only as an opaque or partial event. Critically, **neither mechanism records reads.** A SELECT touches no rows and produces no log record. If the compliance requirement is "prove who *looked at* this data", both are the wrong tool and you need engine-side audit rules on the object. ## Attribution This is where triggers win. A trigger runs in the session that made the change, so it can read a session context variable, a GUC, or the client application name that the application set at request start, and write the *human* actor into the history row. CDC sees the transaction as recorded in the log: the database login, sometimes the transaction id and commit timestamp, and little else — behind a connection pool that means `app_user` for everything. The standard fix on the CDC side is to make identity part of the data: have the application write a small marker row (request id, end-user id, reason) in the same transaction, or carry `updated_by`/`updated_at` columns on the table itself, so the identity flows through the log with the change and the consumer can stitch it together. Say this in an interview — it is the detail that shows you have designed one. ## Operational failure modes For triggers: schema drift is the chronic one. A column added to the base table and forgotten in the history table silently stops being captured; generic "capture the whole row as a document" designs avoid that at the cost of queryability. Purging history is the other — history tables outgrow their parents, so partition by time and drop partitions rather than issuing large DELETEs. For CDC: the reader's position pins the log. If the consumer stalls — a crash, a slow sink, a schema the decoder cannot parse — the engine must retain log segments from that position onward, and the primary's disk fills. A full log volume takes the whole database down, so a CDC audit pipeline needs lag alerting and a documented policy for when to drop the reader and re-bootstrap. Schema evolution is the second: the decoder must map old log records written under an older table shape. ## Choosing - **Use CDC** when you need a complete, low-overhead history of committed data changes across many tables, want it off-box for tamper resistance, and can tolerate seconds of lag and coarse actor attribution improved by in-band identity columns. - **Use triggers** for a small number of tables where the history must be transactionally consistent with the change, must carry the end-user identity synchronously, and must be queryable with a plain join right next to the data. - **Use engine auditing** — not either of these — for logins, DDL, privilege changes, failed attempts, and read access. Most mature systems run all three, each on the job it is actually good at.
- With log-based change data capture, how do you recover the end-user identity that the connection pool hid?Put the identity in the data so it flows through the log. Either carry `updated_by`/`changed_by` and a request id as columns on the audited table, or have the application insert one marker row per transaction into a small `tx_context` table with the end-user id and request id; the CDC consumer joins changes to the marker by transaction id. Either way the identity is committed in the same transaction as the change, so it cannot drift.
- What operational risk does a CDC-based audit pipeline add to the primary database that a trigger-based one does not?The reader's replication slot or log position pins transaction-log segments on the primary until they are consumed. If the consumer crashes, lags, or chokes on a schema change, the engine keeps accumulating log files and can fill the disk, which takes the database down. That means CDC audit needs lag and retention-size alerting plus a decision rule for dropping and re-bootstrapping the reader.
- A regulator asks you to prove who viewed a customer's record last month. Can either mechanism answer that?No. A SELECT changes no rows, so it writes nothing to the transaction log and fires no row trigger; both mechanisms are structurally blind to reads. Answering that question requires engine-side audit rules scoped to the object or column, enabled in advance — auditing is not retroactive, so if it was not turned on last month the honest answer is that the evidence does not exist.
A trigger is a witness standing in the room, writing down who did it as it happens — accurate about people, but slowing the room down and able to be sent away. CDC is reading the building's own tamper-proof ledger afterwards: it missed nothing that was committed, but it only records what was moved, not who carried it.
saying these in an interview costs you the question
- Claiming trigger-based history is tamper-proof, ignoring that a privileged user can disable the trigger, change data, and re-enable it.
- Assuming triggers capture bulk loads and TRUNCATE.
- Expecting CDC to record SELECTs or failed/rolled-back statements.
- Ignoring that trigger auditing roughly doubles write and transaction-log volume on the hot path.
- Running CDC without lag alerting, so a stalled consumer pins log segments and fills the primary's disk.