Temporal Tables vs. Event Sourcing: Pick the Right Audit Trail
For: A CTO or senior backend engineer at a Series B B2B SaaS company who just got hit with a compliance request or a customer dispute that their current audit log — a timestamped 'updated_at' column or a hand-rolled changelog table — cannot answer, and is now deciding whether to retrofit temporal tables or migrate hot entities to event sourcing before the next audit cycle.
Pick temporal tables when your audit question is "what did this record look like on March 3rd?" Pick event sourcing when the question is "why did this record end up this way at all?" Everything else — scale, team size, database vendor — is secondary. A SOC 2 auditor asking for point-in-time state is a temporal tables problem. A customer disputing a bill, or a regulator asking about intent behind a series of changes, is an event sourcing problem. Conflating them is why teams retrofit the wrong thing and end up with two audit systems eighteen months later.
This post walks through the actual decision, compares SQL Server / PostgreSQL temporal implementations against event sourcing with EventStoreDB or Kafka, and tells you honestly where each one falls apart.
The question behind the question
Your updated_at column and hand-rolled changelog table failed for a reason. Usually one of three:
- Someone updated the row twice in the same second and you lost an intermediate state.
- An auditor asked for the state of an invoice as of a fiscal close date and you had no way to reconstruct it without restoring a backup.
- A customer disputed a decision — a price change, a permission revoke, a credit limit adjustment — and you could show that it changed but not why.
The first two failures are point-in-time reconstruction problems. Temporal tables solve them cleanly. The third is an intent problem. No amount of temporal history solves it, because temporal history captures the after-state of each row, not the command or business event that caused the change.
This is the distinction that most comparisons skip. A bi-temporal data model tells you what the database knew and when it knew it. Event sourcing tells you what the business decided and why. They are not competing solutions to the same problem — they are solutions to different problems that happen to both produce history.
What each approach actually is
Temporal tables
System-versioned tables that the database itself maintains. Every UPDATE or DELETE writes the old row to a history table with valid_from / valid_to timestamps. Querying is a FOR SYSTEM_TIME AS OF '2024-03-03' clause. Native in SQL Server, MariaDB, Db2, Oracle. In PostgreSQL you get it via extensions (periods, temporal_tables) or hand-rolled triggers. A bi-temporal model adds a second axis — business_valid_from / business_valid_to — for when a fact was true in the real world, separate from when the database recorded it.
Event sourcing
Instead of storing current state and mutating it, you store an append-only log of domain events (InvoiceIssued, PaymentReceived, CreditLimitRaisedByCFO). Current state is a projection — a fold over the event stream. EventStoreDB, Kafka with a compacted topic, or a purpose-built table in Postgres are all valid substrates. Events are immutable and carry business meaning: actor, reason, correlation ID, sometimes a free-text justification.
Change data capture (a distraction, mostly)
Worth naming because it comes up. CDC (Debezium, Postgres logical replication) streams row-level changes out of your database into Kafka or a warehouse. It's an export mechanism, not an audit architecture. It has the same expressive limits as temporal tables — row diffs, no intent — plus the operational cost of a streaming pipeline. Use CDC to feed a warehouse or a search index. Don't use it as your compliance story.
Head-to-head
| Dimension | Temporal Tables (SQL Server / Postgres) | Event Sourcing (EventStoreDB / Kafka) |
|---|---|---|
| Answers "state at time T" | Native, one SQL clause | Requires replaying events up to T |
| Answers "why did this happen" | No — only shows before/after row values | Yes — events carry actor, command, reason |
| Retrofit cost on existing schema | Low. Add system-versioning to hot tables, keep app code unchanged | High. Requires rethinking write model and rebuilding projections |
| Query ergonomics for auditors | Familiar SQL, joins across history work | Custom tooling needed; auditors rarely read event streams directly |
| Storage growth | Predictable — one history row per update | Higher — every intent captured, plus snapshots |
| Schema evolution | Painful. Column adds/drops need history table migration | Painful differently. Old events must remain readable forever; upcasting logic required |
| Compliance frameworks it satisfies cleanly | SOC 2 CC7, HIPAA §164.312(b), most "prove state" audits | SOX intent trails, PSD2 strong customer auth logs, medical decision provenance |
| Where it fails | Cannot answer "who requested this and why," cannot model business time vs system time without bi-temporal extension | Steep learning curve, projection lag, eventual consistency headaches for read models |
The retrofit decision, honestly
If you're a Series B SaaS with a working Postgres or SQL Server schema and an audit fire to put out, temporal tables win most of the time. Here's why:
- You can retrofit in weeks, not quarters. Enable system versioning on the 5–20 tables that actually matter (users, subscriptions, invoices, permissions, whatever the auditor is pointing at). Application code doesn't change. Reads of history are new SQL, not new architecture.
- Auditors know what a SQL query looks like. They do not know what an event stream is. Handing an auditor a stored procedure that returns the state of the
subscriptionstable on any date is a conversation-ender. - Your ORM keeps working. Event sourcing typically breaks Prisma / TypeORM / Hibernate assumptions about how entities load and save. Temporal tables are invisible to the ORM.
Now the honest counterargument. Temporal tables lie to you in one specific way: they tell you the row changed but not who changed it or why. If your application layer doesn't already write updated_by and a change_reason into the row before the update, your temporal history is just a diff log with no attribution. Fix this at the application level with a set_config('audit.actor', ...) pattern and a trigger, or you have a compliance artifact that fails the first real test.
When event sourcing is worth the pain
Event sourcing is the right answer, not the trendy answer, when:
- The domain is transactional and disputes are frequent. Payments, lending, insurance claims, order management. Any place where "we need to reconstruct the sequence of decisions" is a routine business question, not an annual audit exercise.
- Business intent is genuinely richer than row state. A
CreditLimitRaisedevent carries who approved it, what document justified it, what risk model score triggered the review. None of that fits in a row diff without contorting your schema. - You need to project the same history into multiple read models. A ledger, a fraud detection stream, a customer-facing timeline, and a compliance report from one source of truth.
Where event sourcing fails: eventual consistency between the write side and the read projections will confuse both your team and your users. Schema evolution — what happens when InvoiceIssued v1 needs a new field in v2 — becomes a permanent tax on the codebase. And most teams underestimate the cost of building the tooling auditors and support engineers need to actually use the event log.
The pattern that actually works for most SaaS
Hybrid, and unapologetic about it. Temporal tables for the 80% of your schema where "state at time T" is the real question. Event sourcing scoped to the two or three aggregates where intent matters — billing, permissions, whatever your customers dispute. This is not architectural cowardice; it's matching the tool to the question.
Concretely, for a B2B SaaS with a compliance deadline:
- Enable system-versioning on user, subscription, permission, and financial tables this quarter. Add an application-level actor/reason capture pattern before you turn it on.
- Identify the one or two entities where customers or regulators ask "why." For most SaaS, that's billing adjustments and permission changes. Model those as event streams — even a simple
domain_eventstable in the same Postgres, no Kafka required. - Do not use CDC as your audit story. Use it for analytics if you need it, and keep those concerns separate.
The event sourcing vs change data capture debate is, in practice, a false one — CDC is plumbing, event sourcing is a modeling choice. Confusing them is how you end up with a Kafka cluster and no better answer for the auditor than you had before.
How CodeNicely can help
Most of the audit-trail work we do is retrofit, not greenfield — teams who shipped fast, hit a compliance ask, and need to add history without freezing the roadmap. The GimBooks engagement is the closest analog to what a Series B SaaS CTO is dealing with here: an accounting SaaS with real financial data, where every entity change had to be reconstructible for both users and tax authorities. We worked through the same decision — temporal tables for the ledger-adjacent entities, event-style logging for the transactions where intent matters — and shipped it without rewriting the write path.
If your compliance ask is closer to "prove state at a date" than "prove why," a temporal-tables retrofit on your existing Postgres or SQL Server is usually a two-to-six-week engagement scoped to the tables that matter. If it's closer to intent — payment disputes, KYC decisions, permission escalations — we'd scope a targeted event-sourced module rather than a wholesale rewrite. Our work with Cashpo on lending and KYC audit trails and broader legacy modernization engagements follows this same principle: minimum surface area, maximum audit defensibility.
Frequently Asked Questions
Can I use temporal tables in PostgreSQL, or is it only SQL Server?
Postgres does not have native system-versioned tables the way SQL Server does. You get equivalent behavior via the temporal_tables extension, the periods extension, or hand-rolled triggers writing to a shadow history table. For most teams the trigger approach is fine and gives you more control over what gets captured (actor, reason, correlation ID).
Is event sourcing overkill for a small team?
Usually yes, if applied to the whole system. It's not overkill for a narrow aggregate — say, billing adjustments — where intent genuinely matters and the team is going to be answering "why" questions repeatedly. Scope it to where it earns its complexity. A single domain_events table in your existing database is a valid starting point; you don't need EventStoreDB or Kafka on day one.
Does GDPR's right-to-erasure conflict with immutable audit logs?
It complicates both approaches. For temporal tables, you typically need a documented process to purge history for a specific subject on a verified request. For event sourcing, the common pattern is crypto-shredding — encrypt PII fields per subject and delete the key on erasure, leaving the event structure intact. Neither approach is automatic; both need to be designed for.
How long does a temporal tables retrofit take?
It depends on how many entities need history, how clean your write paths are, and how strict the compliance framework is. Rather than guess, talk to CodeNicely for a personalized assessment — we can usually scope it after a two-hour schema review.
Should we use Debezium and Kafka instead of either of these?
Not as your audit trail. Debezium plus Kafka is a good way to stream row changes into a warehouse or search index, but it inherits the same limitation as temporal tables — it captures row diffs, not business intent — while adding operational cost. Use it for analytics and downstream integrations, not as the answer to a compliance auditor.
Building something in SaaS?
CodeNicely partners with founders and tech teams to ship AI-native products that move metrics. Tell us about the problem you're solving.
Talk to our team_1751731246795-BygAaJJK.png)