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?
- ACID and isolation levelscoming soon
- Optimistic vs pessimistic lockingcoming soon
- Idempotency keyscoming soon
The question
Section titled “The question”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.
What the system has to do, in numbers
Section titled “What the system has to do, in numbers”| 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:
- 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.
- 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 big picture
Section titled “The big picture”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.
The hard parts
Section titled “The hard parts”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.
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 berthWHERE 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:
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.
What exactly gets locked
Section titled “What exactly gets locked”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 = 7changes 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.
| 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:
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 | $needWHERE 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 berthWHERE train_date = $1 AND quota = 'TQ' AND mask & $need = 0ORDER BY coach, berth_noLIMIT 1FOR 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.
Choosing which berth
Section titled “Choosing which berth”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:
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:
- the free run left around the journey (tightest fit first);
- a penalty if the passenger needs a lower berth and this is not one;
- a penalty if this berth is not in the same bay as the rest of the group;
- 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”).
RAC, waitlist and charting
Section titled “RAC, waitlist and charting”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:
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.
Routing every write to one owner
Section titled “Routing every write to one owner”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.
Surviving 10:00:00
Section titled “Surviving 10:00:00”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:
- 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.
- 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.
- 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.
- 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.
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.
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).
When things go wrong
Section titled “When things go wrong”| 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 |
Build a piece of it
Section titled “Build a piece of it”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.
# runnable version: examples/irctc-tatkal-booking/python/berth_allocator.pydef 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// runnable version: examples/irctc-tatkal-booking/js/berth-allocator.mjsconst need = (a, b) => ((1 << (b - a)) - 1) << a;
/** How much free run is left around the journey on this berth. Smaller = tighter fit. */function waste(mask, a, b) { let lo = a; let hi = b; while (lo > 0 && !((mask >> (lo - 1)) & 1)) lo--; while (hi < SEGMENTS && !((mask >> hi) & 1)) hi++; return a - lo + (hi - b);}
function bestBerth(db, a, b, senior) { const rows = db.prepare('SELECT id, kind, mask FROM berth WHERE mask & ? = 0').all(need(a, b)); // Tightest fit first; seniors get lower berths first; then by berth number. const key = (r) => [waste(r.mask, a, b), senior && r.kind !== 'LB' ? 1 : 0, r.id]; rows.sort((x, y) => key(x).reduce((d, v, i) => d || v - key(y)[i], 0)); return rows.length ? rows[0].id : null;}
async function book(db, a, b, senior, stats) { const n = need(a, b); let berthId = bestBerth(db, a, b, senior); // what the user was shown await sleep(20); // the same slow work; the choice above is now stale while (berthId !== null) { db.exec('BEGIN'); // one short transaction: the check and the take are one statement const taken = db.prepare('UPDATE berth SET mask = mask | ? WHERE id = ? AND mask & ? = 0').run(n, berthId, n).changes; if (taken) { db.prepare('INSERT INTO booking (berth_id, from_idx, to_idx) VALUES (?, ?, ?)').run(berthId, a, b); db.exec('COMMIT'); return berthId; } db.exec('COMMIT'); stats.retries++; // an overlapping journey got this berth first: re-read, choose again berthId = bestBerth(db, a, b, senior); } return null;}8 berths x 7 segments (NDLS..NGP); 40 concurrent requests, 20 ms of work each
naive 40 of 40 booked, 276 overlapping pairs, 0 retriescorrect 29 of 40 booked, 0 overlapping pairs, 34 retries
bitmask check: berth booked NDLS-AGC 1100000, journey AGC-BPL needs 0011100, AND = 0 -> it fitsThe 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/.
Trade-offs, and what we’d change
Section titled “Trade-offs, and what we’d change”- 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 BYand 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.
Interview corner
Section titled “Interview corner”“Design a train ticket booking system” is a favourite because it looks like a seat-booking problem and is not. A strong 45 minutes:
- Requirements and numbers (5 min): partial journeys, quotas, waitlist, the 10 AM spike.
- Data model (10 min): berth with segment bitmask, why counters fail, availability as a query.
- Concurrency (15 min): the lost update, then conditional update vs
SKIP LOCKEDvs single writer, with their costs. - Allocation and waitlist (10 min): best-fit, group bookings, segment-aware promotion.
- 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
UPDATEwith 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.
Check yourself
Section titled “Check yourself”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 = 0means 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.
Where to go next
Section titled “Where to go next”Further reading
Section titled “Further reading”Official sources
- All India Radio: charts 8 hours before departure (2025) — when the waitlist freezes.
- All India Radio: Aadhaar OTP for Tatkal (June 2025) — identity checks and the agent embargo.
- All India Radio: 60-day advance reservation period (2024) — how far ahead train-dates are live.
- Lok Sabha: remote location and pooled quotas (2010) — quotas earmarked per station by the zonal railway.
- Lok Sabha: lower berths for senior citizens (2025) — automatic lower-berth allotment and the per-coach numbers.
- Lok Sabha: waitlisted e-tickets after charting — automatic cancellation and refund.
- PIB: next-generation PRS (August 2026) — the move to a new reservation system.
- RBI: turnaround time for failed transactions (2019) — reversal deadlines and compensation.
Databases and engineering
- PostgreSQL: transaction isolation — how
UPDATEre-checks rows at read committed, and serialization failures at repeatable read. - PostgreSQL: SELECT, the locking clause —
FOR UPDATEandSKIP LOCKED. - PostgreSQL: lock management settings —
deadlock_timeout. - MySQL: InnoDB locking reads — current reads for
UPDATEandSELECT ... FOR UPDATE. - Martin Fowler: The LMAX architecture — a single-threaded business logic processor with an input log, the idea behind the single writer per shard.
- Stripe: idempotency — idempotency keys for payments.
- Cloudflare: Building Waiting Room — admission control at the edge.
News
- IANS: IRCTC booking figures (June 2026) — the per-minute peak.
- Business Today: staggered Tatkal timings (2015) — the 10 and 11 AM split.
- IANS: suspicious IDs and bots (June 2025) — the bot share of Tatkal logins.
- PTI via The Week: waitlist cap (June 2025) — 60% and 30% caps and confirmation rates.
- Deccan Herald: RAC and lower berth rules (2025) and second reservation chart (2020).
- The Quint: chart vacancy online (2019) — vacant berths, including between intermediate stations.
- Tribune: seat selection in the new PRS — choosing seats like airlines.
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.