Design a Parking Lot System: The Complete System Design Interview Walkthrough

- Concurrency, not scale is the core problem: a 2,000-spot garage peaks at 5 entries per minute, but two gates cannot claim the same spot simultaneously.
FOR UPDATE SKIP LOCKEDlets PostgreSQL allocate spots to concurrent gate transactions without contention, each skipping rows locked by others.- Redis stores a single integer per spot type as an availability counter, turning every "is the lot full?" check into a sub-millisecond lookup.
- A Lua script makes the Redis check-and-decrement atomic, preventing counter drift when two gates read the same value before either commits.
- Payment gates the spot release: the spot stays occupied until the charge succeeds, eliminating revenue leakage with no audit trail.
- Recovery from Redis failure is one aggregate query against PostgreSQL, grouped by spot type, taking under a second for 2,000 spots.
Everyone starts by drawing classes. Vehicle, Ticket, ParkingSpot with a neat inheritance hierarchy and maybe a ParkingFloor for good measure. Then the interviewer asks "how do you handle two cars entering different gates simultaneously when there's one spot left?" and the UML diagram has absolutely nothing to say.
The parking lot system design interview is deceptively simple. The domain feels familiar, the words are ordinary, every engineer has used one. That familiarity is the trap. What the interviewer actually wants is how you handle physical hardware integration, concurrency at the database level, and a clean separation between the write-heavy transactional path and the read-heavy availability queries. This walkthrough covers all of it. Yes, including the part where you discover your Redis counter lied to you.
Clarify Before You Draw Anything
Three questions define the entire design. Miss any one of them and you'll spend 25 minutes building the Taj Mahal when they wanted a bus shelter.
Is this a single lot or a city-wide network? A single garage is a local system with one database and a few edge nodes. A multi-lot network needs a central aggregation tier, regional read replicas, and a spot-count sync mechanism across locations. Scope this before you draw a single box.
Does it support advance reservations or just walk-up entry? Reservations add a booking service, a TTL-based hold that expires if unclaimed, and a confirmation email flow. Walk-up entry is simpler but requires gate decisions in under two seconds while a real human being idles their car at you. Clarify this early or you'll design both.
What hardware does the system talk to? ALPR (automatic license plate recognition) cameras, IoT per-spot sensors, and barrier arms at gates. These devices produce events, fail independently, and cannot be rebooted mid-rush-hour. Your architecture needs to account for them, including the inconvenient reality that IoT sensors are unreliable as a religion.
For this walkthrough: one large multi-floor lot, walk-up entry only, ALPR cameras at entry and exit gates, per-spot occupancy sensors, and a mobile app for payment.
The Scale Is Modest. Consistency Isn't.
A large urban garage has around 2,000 spots across eight floors. Peak hours are 8-9am and 5-7pm on weekdays.
- 2,000 spots, ~80% daily turnover: roughly 1,600 entry/exit events per day
- Peak load: maybe 300 entries in 60 minutes, or 5 per minute
- Gate response requirement: under 2 seconds (a car is waiting)
This is not a high-throughput distributed system. The write volume is low enough that a single PostgreSQL instance handles it without breaking a sweat. The interesting engineering problem is consistency, not scale, because you cannot sell the same parking spot twice. That single constraint shapes every architectural decision that follows.
Five Boxes for the Parking Lot System Design Interview

Five components. Each does one job. None of them try to be clever.
Gate Service runs on an edge node at each physical gate. It reads the ALPR result, calls the central allocation service, and drives the barrier arm open or closed. It also caches the current available count locally so brief network hiccups don't leave a driver sitting at a closed gate forever.
Spot Allocation Service is the heart of the system. It finds an available spot for the vehicle type, marks it occupied atomically, and returns the location. This is where concurrency control lives.
Ticket Service creates a parking ticket linking vehicle plate, spot, and entry timestamp. It is boring and important. An audit trail.
Payment Service calculates fees at exit, charges the payment method, and signals spot release. It only releases after the charge succeeds, not before.
Redis holds a fast integer counter of available spots per vehicle type. The gate checks this before touching the database. If the counter is zero, the gate displays FULL without ever hitting PostgreSQL. This single integer absorbs every "is it full?" read in the entire system. A beautiful, dumb solution to a boring problem.
Four Tables. Nothing More.
CREATE TABLE parking_spots ( spot_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), floor SMALLINT NOT NULL, spot_number SMALLINT NOT NULL, type VARCHAR(10) NOT NULL CHECK (type IN ('COMPACT','REGULAR','LARGE')), status VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', version INT NOT NULL DEFAULT 0, UNIQUE (floor, spot_number) ); CREATE TABLE parking_tickets ( ticket_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), spot_id UUID NOT NULL REFERENCES parking_spots(spot_id), vehicle_plate VARCHAR(20) NOT NULL, vehicle_type VARCHAR(10) NOT NULL, entry_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), exit_time TIMESTAMPTZ, fee_cents INT ); CREATE TABLE payments ( payment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ticket_id UUID NOT NULL REFERENCES parking_tickets(ticket_id), amount_cents INT NOT NULL, method VARCHAR(20) NOT NULL, status VARCHAR(10) NOT NULL, processed_at TIMESTAMPTZ ); -- Fast spot lookup by availability, type, and floor CREATE INDEX idx_spots_available ON parking_spots (status, type, floor) WHERE status = 'AVAILABLE';
The version column is the optimistic locking field (more on why it exists in a moment). The partial index on status = 'AVAILABLE' keeps the "find me an available compact spot on floor 2" query fast even as the table fills up, because once a spot is OCCUPIED it disappears from the index entirely.
What Happens When a Car Pulls Up

Six steps. Under two seconds. The driver has no idea any of this happened.
- ALPR camera sends
{plate: "ABC-1234", vehicle_type: "REGULAR"}to the Gate Service. - Gate Service reads Redis:
GET spots:available:REGULAR. If zero, show FULL and stop. Driver is sad. - Gate Service calls Spot Allocation Service.
- Spot Allocation Service runs this inside a single transaction:
BEGIN; SELECT spot_id FROM parking_spots WHERE status = 'AVAILABLE' AND type = 'REGULAR' LIMIT 1 FOR UPDATE SKIP LOCKED; UPDATE parking_spots SET status = 'OCCUPIED', version = version + 1 WHERE spot_id = $1; INSERT INTO parking_tickets (spot_id, vehicle_plate, vehicle_type) VALUES ($1, $2, $3) RETURNING ticket_id; COMMIT;
- On success, decrement Redis atomically via Lua script (shown in the next section).
- Gate opens. Display shows floor and spot number. Driver parks.
FOR UPDATE SKIP LOCKED is the clause that makes concurrent allocation work. When two gates hit the same query at the same instant, the first one locks the selected row. SKIP LOCKED tells the second transaction to skip that locked row and grab a different spot instead of blocking. Concurrent allocation, no queue, no contention, as long as there are multiple available spots.
Exit: Pay First, Then the Gate Opens
Payment comes before the gate. Always. Your car isn't going anywhere. The money is.
- Driver scans the ticket QR code at the kiosk or in the mobile app.
- Payment Service calculates the fee:
import math from datetime import datetime HOURLY_RATES = {"COMPACT": 3_00, "REGULAR": 4_00, "LARGE": 6_00} # cents FREE_WINDOW_HOURS = 0.25 # first 15 minutes free def calculate_fee_cents(entry_time: datetime, exit_time: datetime, vehicle_type: str) -> int: duration_hours = (exit_time - entry_time).total_seconds() / 3600 if duration_hours <= FREE_WINDOW_HOURS: return 0 return math.ceil(duration_hours) * HOURLY_RATES[vehicle_type]
- Payment Service charges the method, creates a payment record.
- Only after a successful payment confirmation: mark spot
AVAILABLE, increment Redis counter, update ticket withexit_timeandfee_cents. - Gate opens.
The spot stays OCCUPIED until payment succeeds. A stuck spot is a recoverable problem (ops can fix it in a minute). A free spot that was never paid for is permanent revenue leakage with no audit trail. Pick the recoverable failure.
The Race Condition Everyone Forgets About
Two cars pull up to different entry gates at the same time. One spot left. Both drivers feeling unusually lucky today.

Zero blocking. Both gates get a spot. The database does not care about your feelings.
FOR UPDATE SKIP LOCKED handles the database layer correctly. The second transaction skips the locked row and either finds a different spot or returns "no spots available."
The Redis counter is trickier. Both gates read count = 1 before either commits their database transaction. Both pass the Redis pre-check. One DB transaction succeeds, one fails (no spots available). But if you naively do DECR after a successful commit, the failed transaction never decrements, so the counter stays at 1 forever. You keep thinking there's a spot. There isn't. The next driver is going to have a bad time.
The fix is a Redis Lua script for an atomic check-and-decrement:
# Atomic: check > 0 and decrement in one step. No other Redis command runs between them. lua_script = """ local count = tonumber(redis.call('GET', KEYS[1])) if count and count > 0 then return redis.call('DECR', KEYS[1]) end return -1 """ result = redis_client.eval(lua_script, 1, f"spots:available:{vehicle_type}") if result == -1: return {"error": "no_spots_available"}
Lua scripts run atomically in Redis. The check and the decrement are one operation. If the counter is already zero when the script runs, it returns -1 and the gate rejects the entry without touching PostgreSQL. If Redis is unavailable, fall back to querying PostgreSQL directly. Slower, but the database is always authoritative.
For a deeper look at how Redis handles this kind of atomic coordination, the distributed cache system design walkthrough covers the same patterns at much higher traffic.
Redis Absorbs Every "Is It Full?" Request
At peak hours, hundreds of drivers approaching the lot want to know if there's space before committing to the entrance ramp. Every one of those reads would hit the parking_spots table without a cache, scanning thousands of rows per query.

One integer, hundreds of clients. O(1) lookup, ~1ms, no disk I/O. PostgreSQL watches from afar.
Redis turns that O(n) scan into a single integer lookup. The counter updates on every entry (decrement) and every successful exit (increment). If Redis loses the counter on crash or restart, recovery is one SELECT COUNT(*) WHERE status = 'AVAILABLE' against PostgreSQL, grouped by type, then a Redis MSET to repopulate. Under a second for 2,000 spots.
For a city-wide network with hundreds of lots, you'd push spot-count updates via a message queue like Kafka, with each lot publishing availability events and a central aggregation service maintaining the city-level view. The single-lot Redis counter becomes one shard of a larger map. The same increment/decrement pattern applies at each node.
The 45-Minute Clock
| Time | What to cover |
|---|---|
| 0-5 min | Clarify: single lot vs. network, walk-up vs. reservations, hardware |
| 5-10 min | Capacity math, note that consistency is the core problem not scale |
| 10-20 min | Five-box architecture, entry and exit flows in plain English |
| 20-30 min | Data model, SQL schema, partial index on available spots |
| 30-38 min | Concurrency deep dive: FOR UPDATE SKIP LOCKED, Lua in Redis |
| 38-44 min | Scaling: Redis read path, recovery, network-of-lots extension |
| 44-45 min | Tradeoffs and open questions |
If you finish step 4 in minute 12, you are going too fast. Slow down, explain the SKIP LOCKED behavior out loud, and watch your interviewer write things down.
Tradeoffs Worth Saying Out Loud
Pessimistic vs. optimistic locking. FOR UPDATE SKIP LOCKED is pessimistic. It blocks briefly at the row level. Optimistic locking uses the version field: read the version, write with WHERE version = $old_version, retry on conflict. For a parking lot with low write concurrency (5 entries per minute at peak), pessimistic is simpler and correct. The retry logic of optimistic locking adds code and complexity without a real throughput benefit at this scale. Save the version field for the systems design interview where the interviewer asks you to justify your choice.
PostgreSQL vs. a NoSQL store. A parking spot has a status that must be consistent. Two transactions must not both succeed in occupying the same spot. That's a transactional guarantee. A key-value store like DynamoDB can enforce this with conditional writes, but relational transactions are simpler to reason about for a system this size. Save the NoSQL discussion for systems that genuinely need horizontal write scaling. For similar reasoning on when CP systems win, the Ticketmaster walkthrough covers the same reservation-under-contention pattern at much higher traffic.
What happens when a sensor fails? IoT sensors report spot occupancy, but sensors fail. Batteries die. Someone parks on the sensor. If a sensor reports a spot as empty when a car is actually there, the allocation service tries to assign it. The ALPR camera at entry is the backstop: the system knows which plates are currently inside. A spot mismatch triggers a manual review flag, not a hard failure. Systems involving physical hardware fail physically. Design for graceful degradation, not perfection.
Monthly passes and special rates. The fee calculation above is per-hour walk-up. Monthly permits, disability placards, and corporate accounts all need a rate lookup before the fee calculation runs. Call this out during requirements. A clean design adds a RatePolicy table keyed by vehicle plate or permit number, looked up at exit before calculating the fee. This is a five-minute conversation, not a schema redesign.
For a broader look at how CP-vs-AP decisions compound across a design, The Tradeoff Maze is worth reading before any system design session.
The Short Version
- Clarify scope first: single lot vs. network, walk-up vs. reservations, hardware interface
- The scale is small. Consistency is the actual engineering problem.
- Five components: Gate Service, Spot Allocation, Ticket, Payment, Redis counter
FOR UPDATE SKIP LOCKEDallocates spots concurrently without blocking other gates- A Redis Lua script makes the check-and-decrement atomic, preventing counter drift
- The spot releases only after payment succeeds, never before
- Redis absorbs all availability reads; PostgreSQL only sees booking writes
- Recovery from Redis failure is a single aggregate query against the source of truth
If you want to practice this under actual interview conditions, with a voice-based AI pushing back on your tradeoffs and giving structured feedback on how you communicate your reasoning, SpaceComplexity runs mock system design sessions on exactly these kinds of problems. The parking lot comes up more than you'd expect.