Skip to main content
Event-Driven Architecture · lesson 5 of 10
Event-Driven Architecture intermediate Lesson 5 of 10

CQRS: Separate Write and Read Models, and the Lag Between Them

A denormalized read model only reflects the write log once a projector actually processes new events — real output showing that lag, and closing it.

By SkillByExample · Published

event-driven cqrs python

The read model in CQRS isn’t a live view of the write log — it’s a separately-updated projection that can fall behind, and knowing exactly how far behind is part of the design.

A read model that hasn’t caught up yet

write_log = []

def place_order(order_id, items):
    total = sum(price for _, price in items)
    write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})
    print(f"Order {order_id} written (total ${total})")

read_model = {"order_count": 0, "revenue": 0}
projected_up_to = 0

def catch_up_projection():
    global projected_up_to
    for event in write_log[projected_up_to:]:
        read_model["order_count"] += 1
        read_model["revenue"] += event["total"]
    projected_up_to = len(write_log)

place_order(1, [("widget", 10), ("gadget", 25)])
place_order(2, [("widget", 10)])
catch_up_projection()
print(f"Read model after projecting orders 1-2: {read_model}")

place_order(3, [("gizmo", 40)])
print(f"Write log has {len(write_log)} orders, but read model still says: {read_model} (stale)")

catch_up_projection()
print(f"Read model after the projector catches up: {read_model}")
$ python cqrs_write_read_models.py
Order 1 written (total $35)
Order 2 written (total $10)
Read model after projecting orders 1-2: {'order_count': 2, 'revenue': 45}
Order 3 written (total $40)
Write log has 3 orders, but read model still says: {'order_count': 2, 'revenue': 45} (stale)
Read model after the projector catches up: {'order_count': 3, 'revenue': 85}

place_order only ever appends to write_log — it has no idea read_model exists. catch_up_projection is the only thing that ever touches read_model, and it only runs when called, which is exactly why order 3’s write is real and durable the moment it’s appended, but invisible to any query against read_model until the projector actually runs.

Practice

1. Build a second read model — item popularity — from the same write log.
write_log = []

def place_order(order_id, items):
    total = sum(price for _, price in items)
    write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})

place_order(1, [("widget", 10), ("gadget", 25)])
place_order(2, [("widget", 10)])
place_order(3, [("widget", 10), ("gizmo", 40)])

revenue_model = {"order_count": 0, "revenue": 0}
item_counts = {}

def project_all():
    revenue_model["order_count"] = 0
    revenue_model["revenue"] = 0
    item_counts.clear()
    for event in write_log:
        revenue_model["order_count"] += 1
        revenue_model["revenue"] += event["total"]
        for name, _ in event["items"]:
            item_counts[name] = item_counts.get(name, 0) + 1

project_all()
print(f"Revenue read model: {revenue_model}")
print(f"Item-popularity read model: {item_counts}")
$ python second_read_model.py
Revenue read model: {'order_count': 3, 'revenue': 95}
Item-popularity read model: {'widget': 3, 'gadget': 1, 'gizmo': 1}

Both read models are built from the exact same three events — nothing about the write side changed to support a second, unrelated query shape.

2. Answer a query the read model was never designed for by going straight to the write log.
write_log = []

def place_order(order_id, items):
    total = sum(price for _, price in items)
    write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})

place_order(1, [("widget", 10), ("gadget", 25)])
place_order(2, [("widget", 10)])

read_model = {"order_count": 0, "revenue": 0}
for event in write_log:
    read_model["order_count"] += 1
    read_model["revenue"] += event["total"]

def items_in_order(order_id):
    for event in write_log:
        if event["order_id"] == order_id:
            return event["items"]
    return None

print(f"Read model can answer 'how many orders': {read_model['order_count']}")
print(f"Read model has no concept of 'items in order 2' at all")
print(f"Going straight to the write log instead: {items_in_order(2)}")
$ python query_the_write_log.py
Read model can answer 'how many orders': 2
Read model has no concept of 'items in order 2' at all
Going straight to the write log instead: [('widget', 10)]

CQRS separates the models a system usually queries through, not the only place data lives — the write log stays queryable directly for anything a read model wasn’t built to answer, just less efficiently.

3. Measure staleness directly as a count of unprojected events, across several writes in a row.
write_log = []

def place_order(order_id, items):
    total = sum(price for _, price in items)
    write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})

read_model = {"order_count": 0, "revenue": 0}
projected_up_to = 0

def catch_up_projection():
    global projected_up_to
    for event in write_log[projected_up_to:]:
        read_model["order_count"] += 1
        read_model["revenue"] += event["total"]
    projected_up_to = len(write_log)

def staleness():
    return len(write_log) - projected_up_to

for order_id in range(1, 6):
    place_order(order_id, [("widget", 10)])
    print(f"After writing order {order_id}: staleness = {staleness()} event(s) behind")

catch_up_projection()
print(f"After running the projector once: staleness = {staleness()} event(s) behind")
print(f"Read model: {read_model}")
$ python measure_staleness.py
After writing order 1: staleness = 1 event(s) behind
After writing order 2: staleness = 2 event(s) behind
After writing order 3: staleness = 3 event(s) behind
After writing order 4: staleness = 4 event(s) behind
After writing order 5: staleness = 5 event(s) behind
After running the projector once: staleness = 0 event(s) behind
Read model: {'order_count': 5, 'revenue': 50}

Staleness only ever grows between projector runs and only ever resets to zero when the projector actually runs — a real system would run this on a timer or in response to each new event, but the underlying number being tracked is the same either way.

4. Simulate a crash by starting from a wrong read model, and rebuild it correctly from the write log.
write_log = []

def place_order(order_id, items):
    total = sum(price for _, price in items)
    write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})

place_order(1, [("widget", 10)])
place_order(2, [("gadget", 25)])
place_order(3, [("gizmo", 40)])

read_model = {"order_count": 7, "revenue": 999}
print(f"Read model before 'crash' (already wrong): {read_model}")

def rebuild_read_model_from_scratch(write_log):
    rebuilt = {"order_count": 0, "revenue": 0}
    for event in write_log:
        rebuilt["order_count"] += 1
        rebuilt["revenue"] += event["total"]
    return rebuilt

read_model = rebuild_read_model_from_scratch(write_log)
print(f"Read model rebuilt from the write log: {read_model}")
$ python rebuild_after_crash.py
Read model before 'crash' (already wrong): {'order_count': 7, 'revenue': 999}
Read model rebuilt from the write log: {'order_count': 3, 'revenue': 75}

Whatever garbage read_model held before doesn’t matter — rebuild_read_model_from_scratch ignores it entirely and derives a fresh value purely from write_log, which is the actual source of truth the whole time.

Frequently Asked Questions

Why not just query the write log directly instead of maintaining a separate read model?
You can — going straight to the write log works for any query the read model wasn't built to answer, like finding which items were in one specific order. But the write log is optimized for appending events in order, not for answering 'total revenue this month' efficiently. A read model precomputes that answer so a query doesn't have to replay everything every time it's asked.
Is the staleness window in this lesson a bug?
No — it's the defining tradeoff of CQRS. A read model that's always instantly consistent with every write isn't separated from it in any meaningful sense; the lag is the cost of decoupling the two, and real systems manage it (measuring it, bounding it, or accepting it) rather than eliminating it.
Can one write model feed more than one read model?
Yes — a completely different read model, like an item-popularity count, can be built from the exact same write log a revenue-tracking read model already uses, with no changes to the write side or the existing read model. This is one of CQRS's real advantages: adding a new query need doesn't require touching anything that already works.
What happens to the read model if the process restarts?
Whatever was in memory is gone, but that's fine — the read model is a disposable projection that can always be rebuilt by replaying the write log from scratch, the same recovery property event sourcing gives the write side itself.