Skip to content

Case studies· 9 of 9

How IRCTC Tatkal Booking Works

One berth, many journeys, thousands of requests in the same second. What does the database actually lock?

intermediate34 min readReviewed 22 October 2026
Read first
  • ACID and isolation levelscoming soon
  • Optimistic vs pessimistic lockingcoming soon
  • Idempotency keyscoming soon

Berth 23 in coach B2 is already sold from New Delhi to Agra, and again from Bhopal to Nagpur. At 10:00:00.3 three requests arrive in the same millisecond: Agra to Bhopal, Mathura to Gwalior, and New Delhi to Nagpur. Which of them can have berth 23? How does the database answer that for 1,300 berths in microseconds? And when two of those requests both decide berth 23 is free, what exactly is locked so that only one of them gets it?

A “seats left” counter cannot answer any of these questions. This page builds the system that can. IRCTC and CRIS do not publish the internals of the reservation system, so what follows is how we would build it, using the railways’ published rules (quotas, Tatkal timings, RAC, charting) wherever they shape the design.

Quantity Value
Peak tickets booked in one minute 37,410, on 16 August 2025 (IANS, June 2026)
That peak per second, across all trains about 620
Tatkal opens 10:00 for AC classes, 11:00 for non-AC, one day before the journey (Business Today)
Passengers per Tatkal PNR at most 4
Berths on one long-distance train about 1,300 (illustrative: 22 coaches of 64–72)
Stops on one route 10 to 60, so 9 to 59 segments
Requests per ticket at the peak 10 to 20 (illustrative: search, availability, passengers, payment, retries)

620 bookings a second sounds small, and it is: the write rate is not the problem. Two other things are:

  1. The writes are not spread out. In the first seconds after 10:00 almost every write goes to a handful of popular trains, and within each train to one class and one quota. The contention is on a few hundred rows, not on the database.
  2. A berth is not one unit of stock. It is sold per segment, the stretch between two consecutive stops. A berth can carry three passengers on one trip, one after another. Every question about availability, locking, allocation and the waitlist has to be asked per segment.

The hard requirements, in priority order: no segment of a berth sold twice; every payment ends in a ticket or a refund; and the booking rate stays at its maximum during the worst minute, rather than collapsing.

The parts of a train booking systemWeb and app users pass an edge layer with bot checks and a waiting room, then reach stateless booking servers. Availability reads go to a read model: per-train snapshots that may be a few seconds old. Booking writes go to the inventory shard that owns that train and date, which holds the berth bitmasks, the quotas and the waitlist; station counters write to the same shard. The booking servers also talk to the payment gateway. A charting job reads the shard hours before departure and freezes it into the chart.web and app userscountersedge: bot checks,waiting roombooking serversstateless, manypayment gatewayUPI, cards, banksread modelseconds oldinventory shardtrain + date: masks,quotas, waitlistcharting jobreadswritesdashed: refreshed from the shard every few seconds
Reads and writes take different paths. Availability is served from snapshots a few seconds old; every booking for one train and date goes to the one shard that owns it, which counters write to as well.

Three decisions shape everything below:

  • The unit of ownership is train + date. All berths, quotas and waitlists for train 12627 on 24 October live together, and nothing in a booking ever touches two trains. When data is too big or too busy for one server, it is split into shards, slices that each live on one server, and the partition key is the field that decides which shard a row belongs to. Train + date is the natural partition key here: every row a booking touches is in the same shard, so a booking never needs a lock that spans two servers.
  • Reads are separated from writes. “Available 43” on the search page is a cached count. If it is two seconds stale, a booking fails and the user sees the new count; nothing is oversold, because only the shard decides.
  • Counters and the website share one inventory. A station counter clerk and a phone at 10:00:00 compete for the same rows. There is no separate web quota to hide behind.

The rest of the page is the inside of that inventory shard.

One berth, many journeys: the inventory model

Section titled “One berth, many journeys: the inventory model”

Number the stops on the route 0 to N−1. Segment i is the stretch from stop i to stop i+1, so a route of 8 stops has 7 segments. A journey from stop a to stop b occupies segments a to b−1: New Delhi (stop 0) to Agra (stop 2) occupies segments 0 and 1. For each berth we need to remember which of the 7 segments are sold, and to answer “are segments 2, 3 and 4 all free on this berth?” millions of times a minute. The tool for that is a bitmask, which needs a short detour.

Bits and masks. A bit is one 0 or 1. A computer stores an integer as a row of bits, each worth a power of two: bit 0 is worth 1, bit 1 is worth 2, bit 2 is worth 4, and so on, and the integer is the sum of the bits that are 1. So 99 = 64 + 32 + 2 + 1, which is bits 0, 1, 5 and 6. Using an integer as a bitmask means ignoring its value as a number and reading it as a row of on/off switches, one per thing you track. Here, one switch per segment: 1 is sold, 0 is free. On this page masks are written with segment 0 on the left, the reverse of how binary is usually printed, so that they line up with the route on a map: 1100011 means segments 0, 1, 5 and 6 are sold, and it is stored as the integer 99.

Three operations work on every switch of two masks at once, in a single CPU instruction, and SQL databases support them on integers too:

Operation Rule at each position What we use it for Example (segment 0 first)
AND, & 1 only where both are 1 Do two sets of segments overlap? Zero means no 1100011 & 0011100 = 0000000
OR, | 1 where either is 1 Mark a journey’s segments as sold 1100011 | 0011100 = 1111111
AND NOT, & ~ Clear the right-hand switches Free a cancelled journey’s segments 1111111 & ~0011100 = 1100011

One more operation builds masks: a shift, x << k, slides every switch k positions toward the higher segments, so 1 << 3 is a single 1 at segment 3.

Now the data model. Store each berth’s occupancy as one bitmask:

CREATE TABLE berth (
train_date TEXT, -- partition key: '12627/2026-10-24'
coach TEXT, -- 'B2'
berth_no INT, -- 23
kind TEXT, -- LB, MB, UB, SL, SU
quota TEXT, -- GN, TQ, PQ, LD, ...
mask BIGINT NOT NULL DEFAULT 0, -- bit i = segment i sold
PRIMARY KEY (train_date, coach, berth_no)
);

A journey from stop a to stop b needs the mask need = ((1 << (b − a)) − 1) << a. Read it inside out, for Agra (stop 2) to Bhopal (stop 5), which is 3 segments:

Step Value Mask (segment 0 first) Meaning
1 << 3 8 0001000 a single 1, three places up
− 1 7 1110000 subtracting 1 turns it into three 1s at the bottom
<< 2 28 0011100 slide them up to start at Agra: segments 2, 3 and 4

The berth can take the journey exactly when mask & need = 0 (no segment both sold and wanted); booking it is mask = mask | need; cancelling it is mask = mask & ~need.

One berth as a bitmask of segmentsEight stations from New Delhi to Nagpur, with seven segments between them. Berth 23 is sold New Delhi to Agra Cantt and Bhopal to Nagpur, so its mask is 1100011, segment 0 first. A request from Agra Cantt to Bhopal needs 0011100. The AND of the two masks is all zeros, so the berth is free for that journey. A request from Mathura to Gwalior needs 0110000; the AND has a one in the Mathura to Agra segment, so it does not fit.8 stops, 7 segments, segment 0 firstNDLSMTJAGCGWLVGLJBPLETNGPB231100011need0011100AND0000000need0110000AND0100000AGC→BPL wants segments 2–4MTJ→GWL wants segments 1–2AGC→BPL: AND = 0, it fitsMTJ→GWL: AND ≠ 0, clash on MTJ→AGC
Berth 23 is sold New Delhi–Agra and Bhopal–Nagpur. Agra–Bhopal ANDs to zero and fits; Mathura–Gwalior collides on one segment. One AND instruction answers 'is this berth free for this journey'.

Why not counters? Because availability depends on the journey. With 40 stops there are 780 station pairs, and one booking from stop 3 to stop 9 changes the count for every pair that overlaps it, which is hundreds of counters updated in one transaction, all of them hot. With bitmasks, nothing is stored but the masks. Availability for a journey is computed:

SELECT count(*) FROM berth
WHERE train_date = $1 AND coach LIKE 'B%' AND quota = 'TQ'
AND mask & $need = 0;

That is a scan of a few hundred integers, a few microseconds of CPU, and it is what the read model caches per popular journey. A 64-bit integer covers routes with up to 65 stops; longer routes use two words or a bit string type (PostgreSQL’s bit varying supports & and |). Only stops where the train actually halts get an index.

Quotas are a property of the berth, with a segment range. The railways earmark berths per quota and per station: general quota for journeys from the origin, Tatkal, ladies, and pooled and remote location quotas that are kept for passengers boarding at particular intermediate stations, with the distribution decided by the zonal railway for each train (Lok Sabha answer, 2010). In this model, a pooled-quota berth is a berth whose quota tag is PQ and whose sellable range is limited to certain stations. It is still one mask; the rule only filters which journeys may look at it.

Now watch the model work. Eight berths, seven segments, short journeys first and end-to-end ones after:

SimulationEight berths, seven segments, one bitmask per berth

What to watch for. Each berth is reused by several short journeys, end to end, so a coach with eight berths carries more than eight passengers. Then watch the long journeys at the end: some are turned away even though plenty of cells are still empty, because no single berth is free across the whole route. Press "Cancel a random booking" and see which journeys the freed cells can serve.

The grid shows the property that makes rail inventory harder than airline inventory: free capacity is not the same as sellable capacity. Ten free cells scattered across eight berths sell nothing to a passenger who needs one berth for all seven segments. A passenger cannot change berths at Gwalior, so the question is never “is every segment available somewhere” but “is one berth free across the whole range”. How the system chooses berths therefore decides how much of the train it can actually sell. We come back to that after the locks, because the two problems pull in opposite directions.

Every correct booking does three things: find a berth whose mask fits, set the bits, insert the passenger rows. The question is how to stop two transactions from both finding the same berth free. That needs three database ideas, which the separate page on locking covers in depth and which are worth having straight before the strategies.

A transaction groups statements so that they all commit or none do. It does not, by itself, stop other transactions from changing what you read. At the default isolation level of PostgreSQL (read committed), every statement sees the data committed at the moment it starts, so between your SELECT and your UPDATE another transaction can commit a change to the same row, and your UPDATE will not notice unless you make it check.

A row lock is a mark the database puts on a row on behalf of one transaction, held until that transaction commits or rolls back. An UPDATE takes it automatically on every row it changes. SELECT ... FOR UPDATE takes the same lock while reading. Any other transaction that tries to update or lock that row waits until the lock is released. A plain SELECT takes no lock and waits for nobody: in PostgreSQL and MySQL InnoDB, readers never block writers and writers never block readers.

Pessimistic and optimistic locking are two bets about collisions, built from those pieces:

  • Pessimistic bets that a collision is likely, so it locks the row before reading the value it will decide on (SELECT ... FOR UPDATE). The second transaction queues behind the first and, when its turn comes, reads the new value. Nobody ever decides on stale data; the price is waiting.
  • Optimistic bets that a collision is rare, so it reads without a lock and checks at write time that nothing it depended on has changed. The classic check is a version column: UPDATE berth SET mask = ..., version = 8 WHERE id = 23 AND version = 7 changes one row if the berth is still at version 7, and zero rows if someone else committed version 8 in between. Zero rows means “you lost; read again and retry”. Nobody waits; the price is retries.
Two transactions booking the same berth, three waysThree timelines, each with transaction 1 on the left and transaction 2 on the right, time going down. No locking: both read berth 23 as free, both set the bits and commit, and the berth is sold twice. Pessimistic: transaction 1 locks the row with FOR UPDATE, transaction 2's FOR UPDATE waits; when 1 commits, 2 gets the lock, sees the berth sold and moves to berth 24. Optimistic: both read version 7; transaction 1's update with the condition "version is still 7" changes one row; transaction 2's same update changes zero rows, so it knows it lost and retries.No locking: lost updateT1T2tread B23: freeread B23: freeset bits, COMMITset bits, COMMITPessimistic: lock first, others waitT1T2tFOR UPDATE: lockedFOR UPDATE: waits…set bits, COMMITreads: sold → B24Optimistic: check at write, retryT1T2tread B23 (v7)read B23 (v7)UPDATE if v7: 1 rowUPDATE if v7: 0 rows
The same two transactions, three ways. Without locking, both read 'free' and both commit: a lost update. Pessimistic: the second waits for the lock and then sees the berth sold. Optimistic: both read, both try a conditional write, and the second learns from '0 rows' that it lost.
Pessimistic Optimistic
Lock taken Before reading, until commit Only during the final UPDATE, until commit
The loser Waits, then reads the new value Gets 0 rows, re-reads and retries
Cheap when Collisions are frequent and the work inside is short Collisions are rare
Hurts when Work inside the lock is slow: the queue grows; multi-row locks can deadlock Many requests want the same row: retries pile up and repeat work

The booking problem sits in the awkward middle: at most times of day collisions are rare, and at 10:00:00 thousands of transactions want the same few hundred rows. Keep that table in mind for the four strategies below.

Wrong: read, work, write. Read a fitting berth, compute the fare and build passenger rows, then write mask = mask | need. Under the default isolation level of PostgreSQL and MySQL (read committed and repeatable read), two transactions can read the same old mask, both decide berth 23 fits, and both write. The second OR does not fail: it ORs the same bits again. The database has no idea anything went wrong; the only symptom is two passenger rows on one segment. This is a lost update, and it is invisible in testing because it needs two requests in the same few milliseconds, which is every Tatkal morning.

There are four correct ways to write the transaction. Here they are racing on the same burst:

SimulationTwelve transactions, eight berths, four ways to write the transaction

What to watch for. The first lane sells berth 1 to almost everyone, because every request read it as free before anyone wrote. The coach lock never oversells, but requests finish one after another, so the slowest one waits for all the others; raise the work slider and watch it grow. The last two lanes are both correct and both fast: the conditional update pays in retries, SKIP LOCKED in nothing, until every row is locked.

1. Lock the coach (pessimistic). SELECT ... FROM berth WHERE coach = 'B2' FOR UPDATE locks every row the query reads; the next transaction blocks until the first commits. Never oversells, and is the first thing most people write. Its cost is that the coach becomes a single-file queue: with 15 ms of work inside the transaction, the 12th request waits about 165 ms before it even starts, and at Tatkal volume the queue grows faster than it drains. The lock scope is the whole coach for a decision that touches one row.

2. Conditional update (optimistic). Do the slow work first, then make the check and the take one statement:

UPDATE berth SET mask = mask | $need
WHERE train_date = $1 AND coach = $2 AND berth_no = $3
AND mask & $need = 0; -- 1 row: it is yours. 0 rows: someone beat you; pick again.

This is optimistic locking with a better check than a version column. A version column fails the write whenever anything on the berth changed, including a booking of different segments, Mathura to Agra, that does not touch ours at all. The condition mask & $need = 0 fails only if one of our segments was taken in between, so unrelated bookings on the same berth never cause a retry. It is safe under read committed for a reason worth knowing: in PostgreSQL, when an UPDATE finds that the row was changed by a transaction that committed after its snapshot, it re-evaluates the WHERE clause against the new version of the row before updating it; in MySQL InnoDB, UPDATE always reads the latest committed row version and locks it. Either way, the second transaction sees the new mask, the condition is false, and it updates zero rows. Under PostgreSQL’s repeatable read the same race raises a serialization failure (SQLSTATE 40001) instead, which your code must catch and retry. The row lock is held only from the UPDATE to COMMIT, which is microseconds if nothing slow happens in between. The cost shows in the sim: everyone’s first choice is the same “best” berth, so the losers retry, and retries grow with the number of concurrent requests for the same journey.

3. FOR UPDATE SKIP LOCKED (pessimistic, without the queue). Ask for a free berth and lock it, skipping rows other transactions have locked instead of waiting for them:

SELECT coach, berth_no FROM berth
WHERE train_date = $1 AND quota = 'TQ' AND mask & $need = 0
ORDER BY coach, berth_no
LIMIT 1
FOR UPDATE SKIP LOCKED;

Twelve concurrent transactions get twelve different berths with no waiting and no retries; this is the pattern job queues use to hand out work. Two catches. When every fitting row is locked by someone else, the query returns nothing, so a request can be told “sold out” while a berth it could have had is held by a transaction that is about to roll back. And the berth you get is “the first unlocked one”, not the best one, which matters a lot in the next section.

4. A single writer per train-date (no locks at all). Locks exist because many threads may change the same rows at once. Remove that and the locks go with it: route every write for one train and date to one thread that owns that shard’s masks in memory, and let it process requests one at a time from a queue. No two decisions can interleave, so there is nothing to lock.

Memory alone is lost in a crash, so before replying “booked”, the thread appends the decision to a durable log: a file of decisions in order, from which the masks can be rebuilt by replaying it. “Durable” has a precise meaning. Writing to a file only puts the bytes in the operating system’s memory; they reach the disk later. The system call fsync forces them onto the disk and waits until the disk confirms, which takes far longer than a mask scan. The arithmetic: an allocation is a scan of a few hundred integers, around a microsecond or two, so one thread could decide hundreds of thousands of bookings a second, and the whole train has 1,300 berths. The bottleneck is the fsync. The fix is group commit: collect every decision made in the last 2 ms, write them together, call fsync once, then reply to all of them. If 500 bookings arrive in those 2 ms, they cost one disk flush instead of 500, and each waits at most 2 ms longer. For failover, a standby server reads the same log and replays it, so it holds the same masks and can take over. This is the design we would choose for the shard in the architecture diagram, because the contention is naturally per train and this removes it rather than managing it.

What about SERIALIZABLE? Serializable is the strictest isolation level: the database promises that the result of running transactions concurrently equals the result of running them one after another in some order. PostgreSQL keeps that promise with serializable snapshot isolation: it lets transactions run without blocking, records which rows each one read and wrote, and at commit aborts any transaction whose reads and writes cannot fit into a one-at-a-time order. Two bookings that both read berth 23 as free and both wrote it cannot be put in any order (whichever went second would have seen the berth sold), so one of them is aborted with a serialization failure. It is correct, and it lets you write the naive code. The cost appears under exactly this workload: every transaction that read the same coach is a conflict candidate, so at 10:00 most of them abort and retry, and each retry repeats the slow work. Serializable is a good default for low-contention business logic; for a known hot spot, an explicit conditional update says what you mean and fails cheaply.

Make the booking request itself idempotent. A user on a slow network taps “Book” twice, or the app retries after a timeout. Without protection, that is two holds on two berths for one person, and at Tatkal time the second hold steals a berth from someone else. The client sends a request ID generated once per booking attempt; the bookings table has a unique constraint on it; a second insert with the same ID returns the first booking instead of allocating again.

Never hold a lock across a payment. The user takes one to five minutes to pay. The booking transaction should commit a hold: the bits are set, a booking row has status HELD and an expires_at, and the lock is released at commit. If payment does not arrive in time, an expiry job clears the bits in a new short transaction. Locks are for milliseconds; holds are for minutes.

The lock strategies above decide whether two requests collide. Allocation decides which berth each one gets, and it has three goals that conflict.

Goal 1: keep long runs free. Put each journey where it leaves the smallest free gap around it. This is the best-fit rule that memory allocators use: given free gaps of 8 KB, 3 KB and 20 KB, a 3 KB request goes into the 3 KB gap, so the 20 KB gap stays whole for a large request later. Here the gaps are runs of free segments on each berth. A Mathura–Agra passenger should go on a berth that is already sold New Delhi–Mathura and Agra onwards, not on an empty berth, which it would split into two shorter pieces nobody may want. Here is the cost of ignoring it, on the same journeys:

SimulationPack versus spread, on the same journeys

What to watch for. Both grids accept every short journey. When the end-to-end requests arrive, the spread grid turns more of them away, because its free cells are scattered in short pieces across many berths. Drag "Short journeys first" up and down: the gap between the two grows with load.

With ten short journeys first, packing turns away 4 of 8 end-to-end passengers; spreading the same journeys over any fitting berth turns away 6 and leaves almost twice as many cells empty (28 against 15). In a separate test of 500 random runs, with ten short journeys followed by eight full-route requests, packing kept room for about 3.9 full-route passengers and random spreading for about 1.4. Spreading is exactly what SKIP LOCKED and “pick a random free berth to avoid contention” do. The trick that removes lock contention is the one that wastes the most inventory. This is the strongest argument for the single-writer shard: one thread can run best-fit over the whole train with no contention at all.

Goal 2: respect the passenger. Preferences (lower, side, window) are soft constraints. Some are rules: the reservation system automatically considers senior citizens and women aged 45 and over for lower berths even if they did not ask, subject to availability, and each coach keeps a small number of lower berths for them, about 6–7 in Sleeper, 4–5 in 3A and 3–4 in 2A (Lok Sabha answer, 2025). A group of four wants the same bay of one coach, not four coaches.

Goal 3: be fast and deterministic. The allocation runs inside the critical section, so it must be a short scan with a score, not a search. A workable score for each candidate berth, lowest wins:

  1. the free run left around the journey (tightest fit first);
  2. a penalty if the passenger needs a lower berth and this is not one;
  3. a penalty if this berth is not in the same bay as the rest of the group;
  4. the berth number, to break ties the same way every time.

For a group, allocate the passengers together: try each bay, score the bay as a whole, and take the best bay that fits all four; only if no bay fits, fall back to the best individual berths.

Seat choice changes the contention picture. The next-generation PRS that the railways began migrating to in August 2026 is reported to let passengers choose their seat as airlines do, and to target over 1.5 lakh bookings a minute (Tribune, PIB, August 2026). How that system allocates is not public. In our model, letting thousands of people click the same lower berths at 10:00 turns those rows into hot spots: conditional updates fail far more often, and the interface needs a fast fallback (“that berth is gone; we gave you B2-31, also lower”).

When no berth fits, the booking still succeeds, as RAC (reservation against cancellation) or waitlist (WL). Both are queues with segment ranges, and both have races that a naive design gets wrong.

RAC is half a berth. An RAC passenger shares a side lower berth with another RAC passenger, sitting by day (Deccan Herald, November 2025). Model each side lower set aside for RAC as two slots, each with its own mask. An RAC booking is the same allocation problem on slots instead of berths. Promotion has an order: a freed berth goes to an RAC passenger first, and their freed slot goes to the first waitlisted passenger who fits.

The waitlist is not first in, first out. A cancellation frees some segments of one berth. Only waitlisted journeys that fit entirely inside those segments can use them:

Why waitlist numbers do not confirm in orderA passenger booked from Agra Cantt to Bhopal cancels, which frees segments 2 to 4 on one berth. Four waitlisted journeys are checked in order. WL 1, New Delhi to Nagpur, needs the whole route and does not fit. WL 2, Agra to Gwalior, fits and is confirmed. WL 3, Mathura to Bhopal, starts one segment too early and does not fit. WL 4, Gwalior to Bhopal, fits in what is left and is confirmed. WL 2 and WL 4 confirm before WL 1 and WL 3.8 stops, 7 segments, segment 0 firstNDLSMTJAGCGWLVGLJBPLETNGPB23freedWL 1needs NDLS→NGPWL 2CNFWL 3starts at MTJWL 4CNFchecked in order; each fit takes its bitsWL 2 and WL 4 confirm; WL 1 still waits
The Agra–Bhopal passenger cancels. WL 1 needs the whole route and WL 3 starts one stop early, so neither fits. WL 2 and WL 4 do, and confirm ahead of them. Waitlist numbers are an order of consideration, not a promise of order of confirmation.

So the promotion step is: walk the waitlist in order, and give each entry the best-fitting freed range it fits in; stop when the freed bits are used up. This is why a WL 12 ticket can confirm while WL 3 does not, which passengers often take as a bug.

The race: a fresh booking against the waitlist. When a passenger cancels at 10:00:05, both the promotion job and a brand-new Tatkal request want the freed bits. If the cancellation clears the bits in one transaction and the promotion runs in a later one, the new booking can slip in between, and the people who have been waiting lose a berth they were owed. Two fixes: do the cancellation and the promotions in the same transaction, so the bits are never visibly free; or, with the single-writer shard, process the cancellation and its promotions as one event before taking the next request.

Caps and freezing. The railways cap how long a waitlist may grow, at 60% of capacity in AC classes and 30% in non-AC, and officials have said only about 20–25% of waitlisted tickets usually confirm (PTI via The Week, June 2025). The cap is a check on the length of the queue per train, class and quota, enforced in the same transaction that adds to it, or it is not a cap.

Charting is a batch job that turns live state into a fixed list. The first chart is prepared at least 8 hours before departure (All India Radio, 2025); a second chart has been prepared up to 30 minutes before departure (Deccan Herald, 2020). At charting, online tickets still fully waitlisted are cancelled and refunded (Lok Sabha), and the vacant berths, including ones vacant only between intermediate stations, are published for anyone to see (The Quint, 2019). In our model, charting is: stop accepting waitlist entries, run the final promotions, snapshot the masks, generate the chart from the snapshot, and switch the shard into a “current booking” mode that sells only from the leftover bits. Taking the snapshot inside one transaction, or as one event on the single writer, is what makes the chart consistent with the last booking before it.

The single-writer design and the read model both depend on knowing which shard owns a train and date, and on keeping the read side close to the truth.

How many owners? Bookings open 60 days ahead (All India Radio, 2024). With a few thousand reserved train services a day (illustrative), that is a few hundred thousand live train-dates at any time, each a few hundred kilobytes of masks and waitlists. That fits in the memory of a modest cluster. The obvious mapping, hash(train-date) mod N, has a flaw: change N from 4 shards to 5 and the remainder changes for about four in five train-dates, so most of them move at once. Consistent hashing avoids that. Picture the range of hash values as a circle; each shard is placed at a few points on it, and each train-date belongs to the next shard point clockwise from its own hash. Adding a fifth shard only takes over the train-dates that now fall right before its new points, about one in five, and the rest stay put. Keep the result in a routing table that the booking servers cache.

Hot shards are expected, not a bug. At 10:00 the train-dates for tomorrow’s popular trains are hot, and everything else is quiet. Because each train-date is one owner, a hot one cannot be split across machines. It does not need to be: one train has at most a few thousand berth-segments to decide, and the single writer handles that in memory. What must not happen is two hot train-dates landing on one weak shard; assign tomorrow’s known popular trains to separate shards the evening before, which the railway can do because it knows its own timetable.

Keeping the read model fresh. The shard publishes every committed change (berth, bits, quota) to a change stream: an ordered feed of changes that other systems can read from, such as a Kafka topic or the database’s own change log. Here it can be the durable log itself. The read model applies them and recomputes availability only for the journeys people actually search: computing all 780 station pairs per class per train on every change is wasted work, while computing “AGC→BPL, 3A, TQ” when someone searches it, then caching it for a second or two, costs one scan. Under the Tatkal spike, the cache absorbs thousands of identical searches per popular journey.

At 10:00:00 a popular train’s shard gets most of its day’s write demand in a few seconds, and the site gets many times its normal reads. The railways’ own controls work on the demand: AC and non-AC Tatkal open an hour apart, agents are barred for the first 30 minutes, and since July 2025 Tatkal users must be Aadhaar-authenticated with an OTP (All India Radio, June 2025). The Ministry has said bots made up to half the login attempts in the first five minutes of Tatkal before its anti-bot work (IANS, June 2025).

On the system side, four mechanisms matter, in order:

  1. Admission control. A waiting room at the edge hands out signed places in line and admits users at the rate the shards can commit. A user turned away by the queue costs the edge a few bytes; a user turned away by the database cost it a transaction.
  2. Fail fast in memory. Once the Tatkal quota of 3A on a train is gone for most journeys, the shard keeps a “sold out” summary per class and quota and rejects without touching a row. For a train with 96 Tatkal berths and 20,000 interested users, more than 99% of requests end here.
  3. Reads off the write path. Availability comes from the read model. A stale count costs one failed booking attempt; reading the shard for every search would cost the shard.
  4. No retries from the server side. A booking that fails on a conflict is retried inside the shard (pick the next berth), never by asking the client to resubmit the whole flow.

The sim below shows the first mechanism in isolation: the same minute with and without a waiting room.

SimulationThe same rush, with and without a waiting room

What to watch for. The waiting room sells out several times faster, with no error pages at all, and its winners are the people who arrived first. Without it, the servers spend most of their effort on requests nobody is waiting for, and who wins is closer to a lottery. Set the users slider to 2,000 and the capacity to 2,000: with no overload, the room only adds a little delay.

Payments: when money leaves but no ticket arrives

Section titled “Payments: when money leaves but no ticket arrives”

The berth is in the railway’s database; the money is in the passenger’s bank. No transaction spans both. When one business action spans systems that cannot share a transaction, the pattern is a saga: a sequence of steps, each committed on its own, where every step that can be undone has a compensating step that undoes it if a later step fails. The booking is a small saga: hold the berth (local commit), take the payment (remote), confirm or release (local commit), with a refund as the compensation when the last step cannot happen.

The gap between a seat hold and a paymentTwo timelines from 10:00 to 10:20. The top one, the railway, shows a seat hold from 10:00 to 10:15, after which the seat goes to someone else. The bottom one, the bank, shows the money debited at 10:02, then a long gap while the bank's confirmation is delayed, and the confirmation arriving at 10:16, after the hold ended. A red arrow from the confirmation up to the resold seat marks the gap: money gone, no ticket. A reconciliation step at the end turns it into a refund.RailwayBankseat held for youresolddebited"paid" senttoo late:money gone, no seat10:0010:1010:1510:20Reconciliation finds it, refunds itor re-books it if seats are still free
The hold and the payment run on two clocks. If the bank's confirmation lands after the hold expired and the bits were given to someone else, money has moved and the berth has not, until reconciliation settles it.

Four failures to design for, and the answer to each:

  • Late success. The callback arrives after the hold expired. Try the allocation again for the same journey; if a berth fits, confirm; otherwise refund at once and record why.
  • Duplicate callback. The gateway retries. The callback handler looks up the payment ID first; a repeat gets the same answer and does nothing. This is an idempotency key with the payment ID as primary key of an outcomes table.
  • Lost callback. No handler ever runs. A reconciliation job asks the gateway every few minutes for successful payments and settles any it has no outcome for, through the same handler.
  • Ambiguous timeout. The client does not know if it paid. The status comes from the server, by payment ID, never from retrying the payment.

The bank side has its own deadline: the RBI requires failed debits to be reversed within one to five days depending on the channel, with compensation of ₹100 a day after that (RBI, 2019).

Failure What users see Design response
Expiry job lags 10 minutes Berths held by abandoned sessions show “sold out” Check expires_at at read time too: an expired hold counts as free even before the job runs
Read model stale by 30 s Users see availability, booking fails Accept it; show the fresh count from the failed attempt
Shard primary dies at 10:00:02 Bookings for that train stall Standby replays the log; requests in flight are idempotent by request ID so retries do not double-book
Callback storm after a bank outage Thousands of late successes Late-success path rebooks or refunds; reconciliation catches the rest
Cancellation and promotion in two transactions A new booking takes a waitlisted passenger’s berth One transaction, or one event on the single writer

The inventory model and the conditional update, against SQLite: eight berths, seven segments, 40 concurrent requests for overlapping journeys, each doing 20 ms of work between choosing a berth and writing it. The naive version reads, waits, then ORs the bits in; the correct one makes the check part of the UPDATE and chooses again when it gets zero rows.

berth_allocator.py
# runnable version: examples/irctc-tatkal-booking/python/berth_allocator.py
def need(a: int, b: int) -> int:
"""Bits a..b-1 set: the segments a journey from station a to station b occupies."""
return ((1 << (b - a)) - 1) << a
def waste(mask: int, a: int, b: int) -> int:
"""How much free run is left around the journey on this berth. Smaller = tighter fit."""
lo, hi = a, b
while lo > 0 and not mask >> (lo - 1) & 1:
lo -= 1
while hi < SEGMENTS and not mask >> hi & 1:
hi += 1
return (a - lo) + (hi - b)
def best_berth(db: sqlite3.Connection, a: int, b: int, senior: bool) -> int | None:
rows = db.execute("SELECT id, kind, mask FROM berth WHERE mask & ? = 0", (need(a, b),)).fetchall()
# Tightest fit first; seniors get lower berths first; then by berth number.
rows.sort(key=lambda r: (waste(r[2], a, b), senior and r[1] != "LB", r[0]))
return rows[0][0] if rows else None
async def book(db: sqlite3.Connection, a: int, b: int, senior: bool, stats: dict) -> int | None:
n = need(a, b)
berth_id = best_berth(db, a, b, senior) # what the user was shown
await asyncio.sleep(0.02) # the same slow work; the choice above is now stale
while berth_id is not None:
with db: # one short transaction: the check and the take are one statement
taken = db.execute(
"UPDATE berth SET mask = mask | ? WHERE id = ? AND mask & ? = 0", (n, berth_id, n)
).rowcount
if taken:
db.execute("INSERT INTO booking (berth_id, from_idx, to_idx) VALUES (?, ?, ?)", (berth_id, a, b))
return berth_id
stats["retries"] += 1 # an overlapping journey got this berth first: re-read, choose again
berth_id = best_berth(db, a, b, senior)
return None
python berth_allocator.py (node berth-allocator.mjs prints the same)
8 berths x 7 segments (NDLS..NGP); 40 concurrent requests, 20 ms of work each
naive 40 of 40 booked, 276 overlapping pairs, 0 retries
correct 29 of 40 booked, 0 overlapping pairs, 34 retries
bitmask check: berth booked NDLS-AGC 1100000, journey AGC-BPL needs 0011100, AND = 0 -> it fits

The naive version “booked” all 40 by stacking journeys on top of each other: 276 pairs of passengers share a segment of a berth. The correct version booked the 29 that fit and turned the rest away, with 34 cheap retries caused by stale choices. The verification query is worth keeping as a production invariant check: any two bookings on one berth with x.from_idx < y.to_idx AND y.from_idx < x.to_idx is an oversell.

The payment side (holds that expire, an idempotent callback, reconciliation) is in seat_hold.py and seat-hold.mjs in the same folder. Full code and run instructions: examples/irctc-tatkal-booking/.

  • Single writer per train-date vs row locks in a shared database. The single writer removes contention and makes best-fit free, at the cost of building failover, log replay and routing yourself. Row locks reuse the database’s machinery and are simpler to operate, at the cost of contention on popular trains and pressure to spread allocations. Below a few thousand concurrent bookings per train, the conditional update in PostgreSQL is the simpler choice.
  • Bitmask per berth vs one row per berth-segment. A row per segment makes availability a GROUP BY and a booking a multi-row update with more locks. The bitmask keeps a booking to one row and one statement, and needs care only for routes past 64 stops.
  • Best-fit vs fast. Best-fit costs a scan over candidate berths inside the critical section. On 1,300 berths that is microseconds; on a 50,000-seat stadium it would need an index of free runs.
  • Cancellation with promotion in one transaction. Correct and simple, but a cancellation now does work proportional to the waitlist length. Capped waitlists keep that bounded.
  • At 10× scale the shard per train-date still holds; what breaks first is the read side and the edge. At 0.1×, one PostgreSQL instance with conditional updates does all of this.

“Design a train ticket booking system” is a favourite because it looks like a seat-booking problem and is not. A strong 45 minutes:

  1. Requirements and numbers (5 min): partial journeys, quotas, waitlist, the 10 AM spike.
  2. Data model (10 min): berth with segment bitmask, why counters fail, availability as a query.
  3. Concurrency (15 min): the lost update, then conditional update vs SKIP LOCKED vs single writer, with their costs.
  4. Allocation and waitlist (10 min): best-fit, group bookings, segment-aware promotion.
  5. Spike and payments (5 min): admission control, holds, idempotent callbacks.

The follow-ups interviewers usually ask:

  • “Why not lock the row while the user is paying?” Because a lock is held by a database session for minutes, blocks every reader of that row with FOR UPDATE, and vanishes if the session dies. A hold is data: bits set, a status and an expiry, released by a job.
  • “How do you handle two passengers booking four berths each in opposite order?” Lock ordering by (coach, berth), or one multi-row conditional UPDATE with a row-count check.
  • “How does the waitlist promote correctly with partial journeys?” Walk in order, give each entry a fitting part of the freed bits, in the same transaction as the cancellation.
  • “Where is your bottleneck at 10:00?” Not the write rate. Contention on one train’s rows, then reads and retries; then the answer is partitioning by train-date and admission control.
1. A berth’s mask on a 7-segment route is 1000110 (segment 0 first). Which of these fit: NDLS→MTJ (segment 0), MTJ→GWL (segments 1–2), GWL→ET (segments 3–5)?

Only MTJ→GWL. Read left to right, 1000110 has bits 0, 4 and 5 set. NDLS→MTJ needs bit 0: clash. MTJ→GWL needs bits 1 and 2 (0110000): AND is zero, it fits. GWL→ET needs bits 3 to 5 (0001110): clash on 4 and 5. Reading masks by eye is error-prone, which is why the check is one & in code.

2. You switch from a coach-level FOR UPDATE to FOR UPDATE SKIP LOCKED with ORDER BY berth_no. Latency drops. A week later, the business reports more end-to-end passengers turned away on long routes. Why?

SKIP LOCKED gives each concurrent request the first unlocked berth that fits, not the tightest fit. Short journeys get spread over many berths, chopping free runs into short pieces, so fewer berths stay free for the whole route. Fix it with best-fit ordering inside a single writer, or score candidates by fit and use the conditional update.

3. Your booking transaction runs at PostgreSQL REPEATABLE READ and uses the conditional UPDATE. Under load you see errors with SQLSTATE 40001. Is your data wrong?

No. At repeatable read, PostgreSQL refuses to update a row that changed after your snapshot and raises a serialization failure instead of re-checking the condition. The data is safe; the transaction must be retried. At read committed the same UPDATE would re-evaluate the WHERE and return zero rows instead.

4. A passenger cancels NDLS→BPL at 10:00:05. At 10:00:05.001 a new Tatkal request for AGC→GWL books that berth. WL 1 was AGC→GWL. What went wrong?

The cancellation and the waitlist promotion ran in separate transactions, so the freed bits were visible to new bookings in between. The berth belonged to WL 1. Clear the bits and promote in one transaction, or process cancellation and promotion as one event on the train’s single writer.

  • Rail inventory is per segment: a berth is a bitmask, a journey is a mask, mask & need = 0 means it fits. Counters per station pair do not work.
  • Read, work, write loses updates under default isolation; the database will not warn you.
  • Correct options: lock the coach (simple, serial), conditional UPDATE (short locks, retries), SKIP LOCKED (no waits, worse allocation), or a single writer per train-date (no locks, best allocation, you build failover).
  • Allocation decides sellable capacity. Best-fit keeps long runs free; spreading to avoid contention fragments the train.
  • Waitlists promote by fit, not by number, and promotion must be atomic with the cancellation.
  • Holds are data with an expiry, payments are a saga with idempotent callbacks and reconciliation, and the spike is handled before the database by admission control and fail-fast summaries.
  • ACID and isolation levelscoming soon

    Why read committed lets the lost update through, and what repeatable read and serializable change.

    Databases
  • Optimistic vs pessimistic lockingcoming soon

    The conditional update and FOR UPDATE in general, with their failure modes.

    Databases
  • Deadlockscoming soon

    Lock ordering, detection and timeouts, for multi-berth bookings.

    Databases
  • Partitioning and shardingcoming soon

    Choosing a partition key like train-date, and what happens when one partition is hot.

    Databases
  • Design a payment systemcoming soon

    The payment saga, reconciliation and refunds, designed properly.

    Case studies
  • Rate Limiting

    The admission-control side of 10:00:00.

    Resilience

Official sources

Databases and engineering

News

Edit page

Last updated:

Senior Dev Notes

System design, explained like a mentor would.

Written by Uday. Every page is researched, reviewed by a human, and every code example was run before it was published.