Skip to main content
Event-Driven Architecture · lesson 3 of 10
Event-Driven Architecture beginner Lesson 3 of 10

Event Sourcing: Storing What Happened Instead of What's True Now

A mutable balance field loses its own history the moment you overwrite it. An append-only event log rebuilds the same number by replaying every step.

By SkillByExample · Published

event-driven event-sourcing python

Update a balance field in place and the previous value is gone the instant the new one is written — an event log keeps every step that produced the current number.

Two ways to end up with the same balance

class MutableAccount:
    def __init__(self):
        self.balance = 0

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount

account = MutableAccount()
account.deposit(100)
account.withdraw(30)
account.deposit(50)
print(f"Mutable balance: {account.balance}")
account.balance = 0
print(f"After 'corruption', balance: {account.balance} (no way to recover it)")

print()

events = []

def apply(events):
    balance = 0
    for event in events:
        if event["type"] == "Deposited":
            balance += event["amount"]
        elif event["type"] == "Withdrawn":
            balance -= event["amount"]
    return balance

events.append({"type": "Deposited", "amount": 100})
events.append({"type": "Withdrawn", "amount": 30})
events.append({"type": "Deposited", "amount": 50})
print(f"Event-sourced balance: {apply(events)}")

balance_after_two_events = apply(events[:2])
print(f"Balance after only the first two events: {balance_after_two_events}")

print(f"Full event log: {events}")
print(f"Rebuilt from the log after 'corruption': {apply(events)}")
$ python event_sourcing_basics.py
Mutable balance: 120
After 'corruption', balance: 0 (no way to recover it)

Event-sourced balance: 120
Balance after only the first two events: 70
Full event log: [{'type': 'Deposited', 'amount': 100}, {'type': 'Withdrawn', 'amount': 30}, {'type': 'Deposited', 'amount': 50}]
Rebuilt from the log after 'corruption': 120

Setting account.balance = 0 destroys the mutable version’s only copy of the truth — there is nothing left to recompute from. The event-sourced version never actually stores a balance at all; apply(events) derives it fresh every time, which is exactly why “corrupting” a variable that isn’t the real state has no effect on what apply returns from the log.

Practice

1. Add an Adjusted event type that sets the balance directly (a bank correction), and confirm it composes with the existing events.
def apply(events):
    balance = 0
    for event in events:
        if event["type"] == "Deposited":
            balance += event["amount"]
        elif event["type"] == "Withdrawn":
            balance -= event["amount"]
        elif event["type"] == "Adjusted":
            balance = event["new_balance"]
    return balance

events = [
    {"type": "Deposited", "amount": 100},
    {"type": "Withdrawn", "amount": 30},
    {"type": "Adjusted", "new_balance": 500, "reason": "bank correction"},
    {"type": "Deposited", "amount": 20},
]
print(f"Balance: {apply(events)}")
$ python adjusted_event.py
Balance: 520

Adjusted doesn’t add or subtract — it overrides the running total outright, and every event after it still applies normally on top of the new baseline. Adding a new event type required one new elif branch, not a schema migration on a stored column.

2. Compute the balance after every single event, not just the final one.
def apply(events):
    balance = 0
    for event in events:
        if event["type"] == "Deposited":
            balance += event["amount"]
        elif event["type"] == "Withdrawn":
            balance -= event["amount"]
    return balance

def balance_history(events):
    return [apply(events[:i + 1]) for i in range(len(events))]

events = [
    {"type": "Deposited", "amount": 100},
    {"type": "Withdrawn", "amount": 30},
    {"type": "Deposited", "amount": 50},
    {"type": "Withdrawn", "amount": 80},
]
print(f"Balance after each event: {balance_history(events)}")
$ python balance_history.py
Balance after each event: [100, 70, 120, 40]

balance_history is just apply called on every prefix of the list — a full timeline of the account’s balance falls out of the same function used to compute the current value, something a mutable field with no history simply cannot answer after the fact.

3. Persist events to a real SQLite table instead of a Python list, and replay from a query.
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE events (type TEXT, amount INTEGER)")
conn.execute("INSERT INTO events VALUES ('Deposited', 100)")
conn.execute("INSERT INTO events VALUES ('Withdrawn', 30)")
conn.execute("INSERT INTO events VALUES ('Deposited', 50)")
conn.commit()

def apply_from_db(conn):
    balance = 0
    for event_type, amount in conn.execute("SELECT type, amount FROM events ORDER BY rowid"):
        if event_type == "Deposited":
            balance += amount
        elif event_type == "Withdrawn":
            balance -= amount
    return balance

print(f"Balance replayed from SQLite: {apply_from_db(conn)}")
conn.execute("INSERT INTO events VALUES ('Withdrawn', 40)")
conn.commit()
print(f"Balance after one more real INSERT: {apply_from_db(conn)}")
$ python sqlite_event_log.py
Balance replayed from SQLite: 120
Balance after one more real INSERT: 80

The table only ever receives INSERTs — nothing ever UPDATEs or DELETEs a row — and ORDER BY rowid guarantees replay sees events in the order they were written, which is the one invariant this whole pattern depends on.

4. Confirm that replaying the same events twice gives the same answer, and contrast it with calling a mutable method twice by accident.
def apply(events):
    balance = 0
    for event in events:
        if event["type"] == "Deposited":
            balance += event["amount"]
        elif event["type"] == "Withdrawn":
            balance -= event["amount"]
    return balance

events = [
    {"type": "Deposited", "amount": 100},
    {"type": "Withdrawn", "amount": 30},
]

print(f"First replay:  {apply(events)}")
print(f"Second replay: {apply(events)}")
print(f"Third replay:  {apply(events)}")

class MutableAccount:
    def __init__(self):
        self.balance = 0

    def deposit(self, amount):
        self.balance += amount

account = MutableAccount()
account.deposit(100)
account.deposit(100)
print(f"Calling deposit(100) twice by accident: {account.balance}")
$ python idempotent_replay.py
First replay:  70
Second replay: 70
Third replay:  70
Calling deposit(100) twice by accident: 200

apply is a pure function of the event list, so calling it any number of times can never change the result. deposit, by contrast, mutates state every time it’s called — calling it twice by mistake really does double the balance, which is a class of bug replay is naturally immune to.

Frequently Asked Questions

Isn't replaying every event on every read slow?
For a long-lived aggregate with thousands of events, yes — that's exactly the problem snapshots solve (a later lesson in this topic). For most aggregates, replay is fast enough that the durability and audit benefits outweigh the cost, and you can always add a snapshot later without changing how events are stored.
What happens if two events arrive out of order?
This lesson's `apply` function trusts the list's order completely — a `Withdrawn` before its matching `Deposited` would compute a temporarily negative balance instead of erroring, since nothing here validates sequence. A real event store timestamps or sequences events explicitly so out-of-order delivery can be detected, not just silently misapplied.
Do I need a special database to do event sourcing?
No — the pattern is just 'store the events, derive the state,' which works with a plain list, a JSON file, or a normal SQL table used in an `INSERT`-only way. Dedicated event stores exist for scale and features like snapshotting or subscriptions, not because the core idea requires special storage.
How is this different from a database audit log or trigger-based history table?
An audit log is usually a side effect bolted onto a mutable table — the row still gets overwritten, and the log is a record of that. Event sourcing inverts it: the events *are* the primary storage, and current state is a derived, disposable projection you're always allowed to throw away and rebuild.