Most databases store the present. A users row tells you the email address a customer has right now. A balance column tells you what an account holds today. Get the update wrong, run the UPDATE statement, and the old value is gone. The database is a snapshot, and every write quietly destroys the state that came before it.
Event sourcing makes a different bet. Instead of storing the current state, you store the ordered sequence of things that happened, and you derive the current state by replaying them. The account balance is not a number in a column. It is the sum of every MoneyDeposited and MoneyWithdrawn event that ever landed on that account. The user's email is whatever the most recent EmailChanged event says it is. State becomes a function of history, not a thing you overwrite.
If that sounds abstract, you already use it every day. Git does not store the current state of your files. It stores commits, and your working tree is a projection of them. Your bank does not keep a single "balance" field it edits on every transaction. It keeps a ledger, and the balance is derived. Accounting has run on event sourcing for roughly six hundred years, and the accountants figured out the hard parts long before we gave them a name in software.
The append-only log is the whole idea
Here is the core mechanic, stripped of frameworks. An event is a plain, immutable record of something that occurred, named in the past tense, with the data that was true at that moment.
{ "type": "MoneyDeposited", "accountId": "acct-42", "amount": 100, "at": "2026-07-18T09:00:00Z" }
{ "type": "MoneyWithdrawn", "accountId": "acct-42", "amount": 30, "at": "2026-07-18T09:05:00Z" }
{ "type": "MoneyWithdrawn", "accountId": "acct-42", "amount": 10, "at": "2026-07-18T09:07:00Z" }
You never update these rows. You never delete them. You only append. To answer "what is the balance," you fold the stream:
def current_balance(events):
balance = 0
for e in events:
if e["type"] == "MoneyDeposited":
balance += e["amount"]
elif e["type"] == "MoneyWithdrawn":
balance -= e["amount"]
return balance # 60
That fold is the pattern. The current state, whatever shape you need it in, is a left fold over the event stream. The derived thing has a name worth knowing: a projection. A projection is a read model built by replaying events, and the crucial property is that it is disposable. You can throw the balance away and rebuild it from the log, because the log is the source of truth and the projection is just a cache.
This is the part people fall in love with, and they are right to. Because state is derived, you get things that are genuinely hard to bolt onto a snapshot database later. You get a perfect audit trail for free, because the audit trail is not a feature you added, it is the storage model. You can answer "what did this account look like last Tuesday at 3pm" by replaying events up to that timestamp, a query a normal UPDATE-based system simply cannot answer because the data is gone. And when your product manager invents a new report six months from now that needs data you never thought to aggregate, you do not shrug and say "we only have it going forward." You write a new projection and replay three years of history through it.
The bet you are actually making
None of that is the reason event sourcing is hard. The append-only log is easy. The difficulty is hiding in one word: immutable.
An event, once written, is a permanent fact. And because your code has to be able to read it forever, the shape of that event, its field names, its types, its meaning, becomes a contract that you can never change. Not "should not change." Cannot. A CustomerCreated event you wrote in 2021 must still deserialize and make sense to the code you deploy in 2026. Your event schema is not an internal implementation detail you can refactor on a quiet Friday. It is a public API with an infinite deprecation window, and the only client you can never drop support for is your own past.
This is where teams that adopted event sourcing "for the audit log" discover the real cost. Say your OrderPlaced event stored price as an integer number of cents. A year in, you go international and need a currency. You cannot go back and add a currency field to two million historical events. They were written without one. So now your loading code has to cope with two shapes of the same event: the old ones with no currency, and the new ones with it. Multiply that by every schema decision you get slightly wrong, forever, and you have signed up to maintain a small museum of every version of every event you have ever emitted.
The industry answer is upcasting. An upcaster is a small transformation that runs on read, after deserialization but before your domain logic sees the event, turning an old version into the current one on the fly.
def upcast_order_placed(raw):
version = raw.get("version", 1)
if version == 1:
# v1 had no currency; everything back then was USD
raw["currency"] = "USD"
raw["version"] = 2
return raw
The data on disk never changes. The 2021 event is still exactly what it was. But every load path threads it through the upcaster so the rest of the system only ever sees the current shape. The alternative strategy is versioned event types outright, OrderPlacedV1 and OrderPlacedV2 as distinct classes, which is more explicit and more boilerplate. Either way, the lesson is the same: schema evolution is not a migration you run once, it is a permanent layer of your codebase. A widely repeated production practice, and a good one, is to pull a representative sample of real event streams out of production, freeze them as fixtures in your test suite, and run your projectors and upcasters over them on every build, so the day you break the reading of a 2022 event, a test catches it and not a customer.
You do not edit the past, you reverse it
The immutability rule has a second consequence that trips up everyone coming from CRUD: what do you do when you write a wrong event?
You wrote MoneyDeposited for 1000 when it should have been 100. In a normal database you would UPDATE the row and move on. In an event-sourced system that row is a historical fact, and facts do not get edited. So you do what accountants have done for centuries. You do not reach into the middle of the ledger and erase the entry, which as Greg Young likes to point out is not just frowned upon in accounting, it is illegal. You post a correcting entry. You append a compensating event, a reversal, and then append the correct one.
Accountants have a refined version of this. They avoid partial adjustments precisely because they are painful for an auditor to follow. Instead they do a full reversal: give back the entire original amount, then apply the correct amount, so anyone reading the books can see the mistake, the cancellation, and the intent, all in sequence. Event sourcing inherits this exactly. Your correction leaves a visible trail. The wrong deposit, its reversal, and the right deposit all sit in the log, and the balance projection folds all three into the correct answer. Nothing is hidden, which is the entire point of choosing this model in a domain where hiding is the problem.
This is why event sourcing fits banking, trading, insurance claims, inventory, and anything regulated so naturally. In those domains the requirement to keep every state transition, immutably and in order, is not a nice-to-have you are engineering around. It is the actual business rule. The storage model and the compliance requirement are the same shape.
Where it goes wrong
The failure modes are consistent enough to name.
The first is applying it to everything. Event sourcing is a fit for domains where the history is the value: money, legal state, anything auditable. It is a terrible fit for a settings page. If nobody will ever ask "what was the value of this dropdown last March," folding a stream to compute a checkbox is pure ceremony. The tell is that you find yourself writing events like UserPreferenceChanged and never once querying the history. Store the current value in a row and go home. Event sourcing is a per-aggregate decision, not an architecture you spread across the whole database.
The second is welding it to CQRS and assuming you cannot have one without the other. They pair well, because event sourcing naturally splits the write side (append events) from the read side (query projections), and that split is exactly what CQRS describes. But event sourcing does not require CQRS, and CQRS very much does not require event sourcing. Plenty of teams reach for the whole heavyweight combination when they needed a straightforward audit table. Adopt them separately, for their own reasons.
The third is eventual consistency arriving as a surprise. In most real systems the write side appends an event and the read projections update asynchronously, a beat later. That means a user can complete an action and then not see it reflected on the next screen, because the projection has not caught up. This is a genuine product decision, not a technical footnote. For the user's own just-changed data you usually want to read it back synchronously or optimistically, and let everything else lag. Discovering this in production, when support tickets roll in saying "I saved it and it disappeared," is the expensive way to learn it.
The fourth is unbounded replay. If rebuilding an aggregate means folding fifty thousand events every time you load it, your "just replay the log" superpower becomes a latency problem. The standard fix is snapshots: periodically persist the folded state, and on load start from the latest snapshot and only replay the events after it. A snapshot is not a source of truth, it is a performance cache for the fold, and you can always delete it and rebuild from the raw log. Event stores like EventStoreDB (now Kurrent) and libraries like Marten in the .NET world bake snapshotting and stream loading in, which is a good reason to reach for one rather than reinventing an append-only store on top of a relational table and learning these lessons the hard way.
When to actually reach for it
Event sourcing is not a default. It asks you to give up the one thing every developer finds intuitive, editing the current state, and in exchange it gives you a perfect, replayable, auditable history and the freedom to invent new read models over your past. That trade is a bargain in a narrow set of domains and a tax everywhere else.
Reach for it when the history is the product: ledgers, order lifecycles, regulated workflows, anything where "how did we get to this state" is a question someone will pay to answer. Skip it when the present is all anyone will ever ask for. And whichever way you go, remember the real commitment you are making. It is not the append-only log, which you could write in an afternoon. It is the promise that the event you write today will still make sense to the code you have not written yet, for as long as the system lives. Design the event, not the table, and design it like you can never take it back, because you cannot.

