What a read/write deployment split is and when it fits

A read/write split takes a single monolith artifact and runs it as two separately scaled deployments. The write deployment receives every request that mutates state and connects to the primary database. The read deployment receives requests that only query data and connects to read replicas. Both run the same build. Only configuration and routing differ.

Teams use this pattern for three reasons:

  • Scaling headroom. Read-heavy traffic can scale out on cheap replicas without adding app instances or connections that compete for the primary.
  • Partial failure isolation. A traffic spike on a heavy read endpoint exhausts the read pool, not the pods handling checkouts or form submissions.
  • Reversibility. If the split causes problems, you send all traffic back to the write deployment and you are running the original monolith again.

The isolation is partial. Both deployments share the same code, so a bad release or a memory leak hits both. They also share the primary, because replicas replay its writes. Caches, queues and third-party dependencies are shared too. The split separates compute pools. It does not create independent failure domains.

The pattern fits best when the monolith is read-heavy and replicas already exist and are trusted. Avoid it in these cases:

  • The primary is write-bound, so offloading reads frees nothing that matters.
  • Write contention, such as hot rows or lock waits, is the actual bottleneck.
  • Key endpoints cannot tolerate stale reads.
  • The team cannot operate, monitor and deploy a second deployment.
                         Clients
                            |
                            v
              +---------------------------+
              | Router / load balancer    |
              | mutations -> write deploy |
              | queries   -> read deploy  |
              +---------------------------+
                  |                   |
        writes    |                   |   reads
                  v                   v
     +----------------------+  +----------------------+
     | Write deployment     |  | Read deployment      |
     | same build, N pods   |  | same build, M pods   |
     | config: primary      |  | config: replicas     |
     +----------------------+  +----------------------+
          |        |                 |         |
          |        +--------+--------+         |
          |                 |                  |
          |                 v                  |
          |   +---------------------------+    |
          |   | Shared: cache, queues,    |    |
          |   | third-party dependencies  |    |
          |   +---------------------------+    |
          v                                    v
     +-----------+                      +--------------+
     |  Primary  |--------------------->|  Replicas    |
     |  writes   |  async replication   | reads, stale |
     +-----------+                      +--------------+

One build runs as two separately scaled deployments: the router sends mutations to the write deployment on the primary and queries to the read deployment on replicas, while both still share code, caches, queues, third-party dependencies and, through replication, the primary.

Classifying endpoints into read and write paths

Start from the assumption that every route writes until you have proven otherwise. The HTTP method is a hint, not a guarantee. A GET handler that looks read-only often touches the database through middleware, ORM hooks or background helpers that nobody reads during a review.

Hidden write Where it usually lives Why it is missed
Session refresh or rotation Auth middleware Runs on every request, outside the handler
Last-seen timestamp User loader or ORM callback Treated as cheap bookkeeping
Audit or access log rows Decorators, interceptors Logged to the primary database, not a log pipeline
Cache-warming insert Read-through cache fallback Only fires on a cache miss
Lazy migration or backfill Model accessors Only triggers on old-format records

To find these, run each route against a database user with no write privileges in a test environment and record every failure. Use that output to build a route manifest: a file in the repository that marks each route as read or write and records the reason for any exception. The manifest is the single source of truth. The edge router and the read-only guards both derive from it.

Misclassification fails in one of two ways. With strong guards, the read deployment returns permission errors, often only on cache misses or for old records, so the bug appears intermittent. With weak guards, writes reach a replica. Depending on the database, they either fail there or succeed against the wrong node and drift silently.

Classification decays as features are added. Keep it accurate with a CI check that fails when a route is missing from the manifest, and replay read-route tests under the no-write database user on every build.

"""CI check: every route must appear in routes.json, and exceptions must say why."""
import json
import sys

from myapp import create_app  # placeholder Flask app factory

SAFE_METHODS = {"GET"}
IGNORED_METHODS = {"HEAD", "OPTIONS"}  # added automatically by Flask


def main() -> int:
    # Example entries:
    #   "GET /products/<int:id>": {"class": "read"}
    #   "GET /dashboard": {"class": "write", "reason": "last_seen_at update in user loader"}
    with open("routes.json") as f:
        manifest = json.load(f)

    app = create_app()
    errors, seen = [], set()
    for rule in app.url_map.iter_rules():
        if rule.endpoint == "static":
            continue
        for method in sorted(rule.methods - IGNORED_METHODS):
            key = f"{method} {rule.rule}"
            seen.add(key)
            entry = manifest.get(key)
            if entry is None:
                errors.append(f"not in manifest: {key}")
            elif entry.get("class") not in ("read", "write"):
                errors.append(f"bad class for {key}: {entry.get('class')!r}")
            # The method is only a hint: when the class disagrees with it, require a reason.
            elif (entry["class"] == "read") != (method in SAFE_METHODS) and not entry.get("reason"):
                errors.append(f"exception without reason: {key}")

    # Stale entries mean the manifest no longer matches the code.
    errors += [f"stale manifest entry: {k}" for k in sorted(manifest.keys() - seen)]

    print("\n".join(errors) or "route manifest OK")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

A CI script for a Flask app that fails the build when a route is missing from the manifest, when an entry is stale, or when a route is classified against its HTTP method without a recorded reason.

Routing traffic at the edge

Once the manifest is trustworthy, the edge has to act on it. There are three common ways to express the split.

Approach How it decides Main risk
Method-based GET/HEAD go to the read pool, everything else to the write pool GET handlers with side effects, and POST-based search endpoints, land in the wrong pool
Path-based Explicit path prefixes or patterns per pool Hand-maintained lists fall behind the code
Header-based A trusted gateway or internal caller sets a routing header Clients can spoof the header unless the edge strips it first
Generated from manifest Rules are rendered from the same manifest that CI checks The rendering pipeline is one more thing to deploy

Edge rules are application logic, even though they live in infrastructure config. If a route is classified wrong at the edge, the read-only layer rejects it as a failed write, or a read takes write-pool capacity. Neither failure shows up in unit tests. Generating rules from the manifest gives code and edge a single source of truth. Ship rule changes with the application release, not on a separate infrastructure schedule.

An illustrative rule set, evaluated in order:

  1. Strip any inbound routing header from external traffic.
  2. Send write-classified paths to the write pool, whatever the method.
  3. Send GET/HEAD requests on read-classified paths to the read pool.
  4. Send everything else to the write pool.

The default in step 4 matters. An unknown route sent to the write pool still works, just less efficiently. An unknown route sent to the read pool breaks.

# conf.d/split.conf (included in the http context)
# GENERATED from routes.manifest by CI. Do not edit by hand.

upstream read_pool  { server read-app.internal:8080; }
upstream write_pool { server write-app.internal:8080; }

# Only safe methods are eligible for the read pool.
map $request_method $safe_method {
    GET     1;
    HEAD    1;
    default 0;
}

# Key is "<safe>:<normalized path>". Regexes are checked in file order,
# so write-classified paths win whatever the method.
map "$safe_method:$uri" $pool {
    # Step 2: write-classified paths, any method.
    ~^[01]:/api/(orders|payments|carts)(/|$)   write_pool;
    # Step 3: read-classified paths, GET/HEAD only.
    ~^1:/api/(products|categories)(/|$)        read_pool;
    ~^1:/api/reviews(/|$)                      read_pool;
    # Step 4: unknown routes degrade to slower, not broken.
    default                                    write_pool;
}

server {
    listen 80;
    server_name shop.example.com;

    location / {
        # Step 1: overwrite the routing header so clients cannot pick a pool.
        # An empty value means nginx does not forward the header upstream.
        proxy_set_header X-Route-Pool "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # $pool resolves to one of the upstream groups defined above.
        proxy_pass
    }
}

An nginx rule set, as it might be rendered from a route manifest, that drops the client routing header, pins write-classified paths to the write pool, sends GET/HEAD on read-classified paths to the read pool, and defaults everything else to the write pool.

Running one artifact in two modes and enforcing read-only in depth

Read the role flag once at startup, from a single environment variable such as APP_ROLE=read or APP_ROLE=write, and make everything else derive from it. The flag selects the database connection string, decides whether background workers and schedulers start, and sets the behavior of the write guard. If it is missing or unrecognized, refuse to boot. A pod that silently defaults to write mode defeats the split.

Layer the read-only enforcement so that no single mistake reaches the primary:

  • Database grants. Read pods connect as a user with only SELECT privileges. A misrouted write then fails with a permission error, even if it somehow reaches the primary.
  • Replica behavior. Standbys in most mainstream databases reject writes by design, which gives you a second barrier you don't have to build.
  • Application guard. In read mode, wrap the connection or ORM session so any insert, update, delete, or explicit write transaction raises immediately with the route name attached. Run the read-path test suite with the role set to read. Violations then fail CI instead of surfacing later as permission errors in production logs.

Shared concerns need an explicit home:

  • Caches. Read pods may populate the cache. Invalidation belongs to the write path.
  • Sessions. Keep them in an external store so either pool can serve them. Watch for "last seen" updates, which are writes.
  • File uploads. These are write path only.
  • Websockets. Pin them to one pool, because they outlive individual requests.
  • Background jobs. Only write pods run them. Decide deliberately whether read pods may enqueue jobs.
import os
import re
import sys
from contextvars import ContextVar

from sqlalchemy import create_engine, event

# Read the role once at startup. Missing or unknown values stop the boot,
# so a pod can never fall back to write mode without anyone noticing.
ROLE = os.environ.get("APP_ROLE")
if ROLE not in ("read", "write"):
    sys.exit(f"APP_ROLE must be 'read' or 'write', got {ROLE!r}")

# Everything below derives from ROLE: the DSN, the workers, and the guard.
# READ_DATABASE_URL points at a replica as a SELECT-only user,
# e.g. postgresql+psycopg2://app_reader:CHANGE_ME@replica.internal/app
DSN = os.environ[f"{ROLE.upper()}_DATABASE_URL"]
START_WORKERS_AND_SCHEDULERS = ROLE == "write"

# Middleware sets this per request so violations name the offending route.
current_route: ContextVar[str] = ContextVar("current_route", default="<unknown>")

# A coarse check on the leading keyword. It does not catch writes inside a
# leading CTE (WITH ... INSERT); the read-only session and SELECT-only grants
# below are the backstop for those.
WRITE_SQL = re.compile(
    r"^\s*(INSERT|UPDATE|DELETE|MERGE|CREATE|ALTER|DROP|TRUNCATE)\b", re.IGNORECASE
)

class ReadOnlyViolation(RuntimeError):
    pass

# In read mode, also ask PostgreSQL (via libpq options) to open every
# transaction as read-only.
connect_args = {"options": "-c default_transaction_read_only=on"} if ROLE == "read" else {}
engine = create_engine(DSN, pool_pre_ping=True, connect_args=connect_args)

if ROLE == "read":
    @event.listens_for(engine, "before_cursor_execute")
    def block_writes(conn, cursor, statement, parameters, context, executemany):
        # Raise before the statement reaches the database, so CI fails loudly.
        if WRITE_SQL.match(statement):
            raise ReadOnlyViolation(f"write in read mode on {current_route.get()}: {statement[:80]}")

A single APP_ROLE flag selects the connection string and decides whether workers start. In read mode, it adds a SQLAlchemy guard that rejects write statements, naming the route, and opens PostgreSQL sessions as read-only. This layers on top of SELECT-only grants.

Handling replication lag and read-your-own-writes

Asynchronous replicas apply the primary's changes some time after they commit. That delay is usually small, but it grows under heavy write bursts, long-running transactions, or vacuum and backup activity on the replica. A user who saves a profile and is immediately redirected to a page served by the read deployment can see the old value. To them it looks like the save failed, so they retry, file a ticket, or submit the change twice.

There are three common ways to close that gap:

Option How it works Cost
Time-window pinning After any write, the edge routes that user to the write deployment for a fixed window, longer than typical lag Needs session affinity at the edge. A fixed window fails when lag exceeds it
Write marker in cookie or token The write response sets a marker (timestamp or replication position). Reads carrying it go to the primary until a replica has caught up Needs a lag or position check per request. Markers must be signed so clients cannot forge them
Consistency-sensitive reads to primary Specific endpoints, such as checkout totals and permission checks, always read from the primary Adds per-endpoint classification that has to be maintained

Each option adds routing logic and moves some read load back onto the primary. Measure how much moves rather than assuming it is negligible.

When a replica fails over or lag spikes past your threshold, the safe fallback is to send its reads to the primary. That fallback can double or triple primary load at the moment the system is already degraded. Size the primary and its connection pool for full read fallback, or decide in advance which reads can be shed or served stale instead.

Scaling, connection budgets, observability, and rollout

Scale each deployment on its own signal

The write deployment usually saturates on primary latency or lock wait time. The read deployment scales on request rate or CPU. If both share one autoscaling policy, a read spike adds write pods that hold idle primary connections. Give each deployment its own policy and its own pool budget.

Illustrative estimate (assumed numbers, not a benchmark). Connections per target = pods × processes per pod × pool size.

  • Write: 6 pods × 4 × 5 = 120 on the primary.
  • Read: 20 pods × 4 × 5 = 400, split over 2 replicas, so 200 each.
  • Read fallback to primary: 120 + 400 = 520.

If the assumed primary limit is 500, fallback fails on connections before it fails on CPU. Use the autoscaler's maximum pod count, not the typical count, and leave headroom for migrations and admin sessions. A connection pooler in front of the primary also helps.

Observe by role

Tag every metric, log line, and trace with the deployment role. Without that tag, a read-path regression is averaged in with write traffic and hard to see. Put replica lag on the same dashboard as read-path error and stale-read rates, so you can tell whether errors follow lag. Page on any write attempt from the read deployment. Each one is either a misclassified endpoint or a broken read-only guard.

Roll out reversibly

Shadow or canary a small share of read traffic. Compare response bodies and latency percentiles against the original deployment. Keep one switch that routes all traffic back to the original deployment. Release both deployments from the same artifact and config version in the same pipeline run, so feature flags and schema expectations stay in sync.

Alternatives, cost, and engineering time

Before committing to the split, check whether a cheaper option already solves your problem. The deciding question is usually whether you need process isolation or just less database load.

Option What it fixes Main cost
Vertical scaling Short-term headroom Hard ceiling, and read spikes still starve writes
Read/write splitting inside one deployment Primary database load Shared process, pool, and autoscaler
Caching layer Hot, repeated reads Invalidation logic, little help for long-tail queries
Separate read/write deployments Write path isolated from read surges Two fleets to operate
Service extraction Independent ownership and data Large rework, cross-service consistency

The split's operational cost is real but bounded. You maintain two dashboards and two scaling policies. Each needs its own thresholds, because read and write fleets saturate on different signals. Config drift is the quieter risk: an environment variable set on one deployment but not the other causes bugs that appear on only one path. The shared pipeline run guards against this. The route manifest also needs an owner. Every new endpoint is a classification decision, and a CI check that fails on unclassified routes keeps the manifest from going stale.

Compared with extracting services, the split avoids data ownership boundaries, inter-team API contracts, and distributed transactions. The code structure stays the same.

It still produces useful groundwork. The manifest records which endpoints mutate state, and read-only enforcement shows which modules touch the write connection. If the team decomposes later, those are the natural seams to start from. If it never does, none of the work is wasted.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to split monolith read write deployments, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.