Parking Lot Design Interview: Class Model, Pricing, and the Two-Gate Race
Quick Overview
A worked answer to the parking lot low-level design question, built around six real interview questions from Uber, Amazon, DocuSign and BlackRock. Covers requirements clarification, the two interfaces worth extracting (spot assignment and pricing), per-size distance heaps with hand-checked complexity, a Decimal rate card with grace-window and rounding rules, and the check-then-act race that separates mid from senior, including why SELECT ... FOR UPDATE SKIP LOCKED returning zero rows does not mean the lot is full.
Parking lot design is the default object-oriented design question at Uber, Amazon and BlackRock, and at most companies that run a dedicated low-level design round. You get roughly 45 minutes to clarify requirements, sketch a class model, choose how spots get assigned, price a stay, and handle two cars racing for the last space. There is no canonical answer. The grade comes from whether your design absorbs a new requirement at minute 35 without a rewrite.
Key Takeaways
- Spend four to six minutes turning the prompt into five decisions: gate count, size compatibility, assignment policy, pricing shape, reservations. Gate count is the one that decides whether you are answering an OOD question or a distributed-systems question.
- Extract exactly two interfaces, spot assignment and pricing, and inject both through the constructor. An interface nobody injects is decoration.
if free: take()across two gates is a race. Name where the claim becomes atomic even if nobody asks; unprompted, it is the clearest mid-versus-senior signal in the round.- Per-size distance heaps buy O(log n) assignment and throw adjacency away. A bus needing k consecutive spots does not extend that structure, it replaces it.
- Keep money in
Decimaland keep rounding rules in the rate card. Before writing a grace window, ask whether minute 16 bills from entry or from the end of the grace period. Same one line of code, different revenue model.
Turn the prompt into five decisions before you draw a class
Asked at Uber — Design an object-oriented parking lot system Model a lot with several levels, each holding spots sized small, medium or large. Motorcycles, cars and buses arrive, each vehicle fits only compatible sizes, and a bus may need several adjacent large spots. Support entry that finds and holds a spot, exit that frees it and charges for time parked, and availability queries broken down by size and by level.
Read that prompt twice and you will find requirements most candidates skip. Compatibility is not a lookup, because a motorcycle fits three sizes and the order matters. Availability is queried per level, which quietly rules out a single global free list. And one sentence about buses needing adjacent spots is the thing that will break whatever data structure you reach for first.
That is the shape of every version of this question: the trap is in a clause, not in a follow-up. So convert the prompt into decisions before you name a class.
How many entrances and exits? One gate means a single process can hold a lock. Multiple gates means the lock has to live where the state lives, and you are in distributed-systems territory for the rest of the hour. Nothing else you ask changes the design this much, and it subsumes the scale question, since 500 spots behind one gate fits in memory and a national operator does not.
Which vehicles fit which spots? Motorcycle, compact and large is the standard set. The follow-up that matters is whether a motorcycle may take a large spot. If yes, you need a preference order rather than a mapping.
Nearest spot or any spot? "Any free spot" is a set pop. "Nearest to the elevator" is a heap. They cost about the same to write. Picking one and then being unable to change it is what costs you.
What does pricing look like? Flat, hourly, tiered, daily cap, free first fifteen minutes. Pick one and structure it so the others drop in.
Are there reservations? A reserved spot is neither free nor occupied and the hold expires, which is more structural than it sounds. The concurrency section works through what it costs.
License-plate formats, payment-provider integration, and what happens when the barrier arm jams are not worth your minutes. Note them out of scope and move.
The failure mode here is memorized. A candidate opens with eight classes and a full set of getters, hardcodes three vehicle sizes, hangs calculatePrice() off Ticket, and stops. Then the interviewer says "EVs need charging spots" and the candidate starts adding boolean fields.
Two interfaces carry the design; everything else is a record
Asked at Uber — Design a Parking Garage Object Model Build the object model for a multi-floor garage serving motorcycles, cars, vans and electric vehicles across compact, large, accessible and charging spots. Name the classes, the relationships between them, and the data structures underneath. Then defend how that model handles spot allocation, pricing, and entries and exits happening at the same moment.
This one asks for the model and nothing else, which makes it a clean test of where you put your seams.

Two things to defend out loud. SpotAssigner and RateCard are interfaces because they are the two things the interviewer will change on you, and both arrive through ParkingLot's constructor in the code below. An interface nobody injects is decoration.
Ticket holds no pricing logic. It is a record of a stay, and price is a function of that record plus a rate card. Put pricing on the ticket and changing weekend rates means editing the class that also handles storage and display.
The ticket holds the Level object, not an index into a list. An index only works while the list stays ordered and contiguous, which stops being true the day someone adds B2 underneath B1.
Skip VehicleFactory. Naming patterns for their own sake reads as pattern-matching rather than design.
The EV requirement in this prompt is where the model gets tested. The tempting move is is_ev: bool on Spot, and it survives until accessible, oversized and compact-EV arrive too. A features: frozenset[str] on the spot plus a required-features set on the request scales further, at the cost of clean per-size heaps, since you now index by feature set and the number of indexes grows with the combinations people actually request. Either answer is defensible. Not noticing there was a choice is not.
Spot assignment is a policy you swap, not a loop you rewrite
Asked at DocuSign — Design a multi-level parking system Build the backend for a facility organised into floors, rows and numbered spots, with EV-capable spots flagged separately. Allocate on entry, release on exit, and answer availability queries by spot type and by floor. Some vehicles need several neighbouring spots, and adjacency here is defined precisely: consecutive indexes within the same row.
That last constraint is the interesting one, and it is worth building the fast path first so you can see exactly what it costs you.
import heapq
import threading
from dataclasses import dataclass
from enum import Enum
from typing import NamedTuple, Protocol
class Size(Enum):
MOTORCYCLE = 1
COMPACT = 2
LARGE = 3
# Preference order: smallest fitting spot first, so a motorcycle doesn't
# burn a large spot while a truck circles the garage.
FITS = {
Size.MOTORCYCLE: (Size.MOTORCYCLE, Size.COMPACT, Size.LARGE),
Size.COMPACT: (Size.COMPACT, Size.LARGE),
Size.LARGE: (Size.LARGE,),
}
@dataclass(frozen=True)
class Spot:
id: str
size: Size
distance: int # walking distance to the elevator
class Level:
def __init__(self, index: int, spots: list[Spot]):
self.index = index
self._lock = threading.Lock()
# One min-heap per size, ordered by distance. The id is a tiebreaker
# so heapq never has to compare two Spot objects (they aren't ordered).
self._free: dict[Size, list] = {size: [] for size in Size}
for spot in spots:
heapq.heappush(self._free[spot.size], (spot.distance, spot.id, spot))
def take(self, vehicle_size: Size) -> Spot | None:
with self._lock:
for size in FITS[vehicle_size]:
heap = self._free[size]
if heap:
_, _, spot = heapq.heappop(heap)
return spot
return None
def release(self, spot: Spot) -> None:
with self._lock:
heapq.heappush(self._free[spot.size], (spot.distance, spot.id, spot))
class Placement(NamedTuple):
spot: Spot
level: Level
class SpotAssigner(Protocol):
def assign(self, levels: list[Level], size: Size) -> Placement | None: ...
class LowestLevelFirst:
"""Try levels in the order given; within a level, smallest fitting size, then nearest."""
def assign(self, levels: list[Level], size: Size) -> Placement | None:
for level in levels:
spot = level.take(size)
if spot is not None:
return Placement(spot, level)
return None
take is O(k + log n), where k ≤ 3 is the number of fitting sizes and n is the free spots of the size it lands on. release is O(log n). Across L levels, assign is O(L·k + log n), because the levels it skips only pay for empty-heap checks and exactly one heappop ever happens. Space is O(n) across all heaps.
Naming the policy before the interviewer notices it is implicit is worth ten seconds: smallest fitting size, nearest within that size, lowest level first. Want every vehicle to get the nearest fitting spot regardless of size instead? That is a different assign that compares the tops of the three heaps rather than taking the first non-empty one, and ParkingLot does not change. The constructor parameter is what buys that.
Now the adjacency requirement. Free spots ordered by distance tell you nothing about which spots are neighbours, so the heaps have already discarded the only fact a bus cares about. Serving it means keeping spots ordered by index within a row and scanning for k consecutive free ones, which is O(n) per row rather than O(log n). Say plainly that this is the requirement that replaces the data structure instead of extending it. Interviewers notice when a candidate defends a structure past its range.
The lot itself:
import uuid
from decimal import Decimal
@dataclass
class Ticket:
id: str
plate: str
spot: Spot
level: Level
entry_ts: float
class LotFull(Exception):
pass
class ParkingLot:
def __init__(self, levels: list[Level], assigner: SpotAssigner, rate_card):
self.levels = levels
self.assigner = assigner
self.rate_card = rate_card
self._lock = threading.Lock()
self._open: dict[str, Ticket] = {}
self._by_request: dict[str, Ticket] = {} # needs a TTL in real life
def park(self, plate: str, size: Size, now: float, request_id: str) -> Ticket:
with self._lock:
done = self._by_request.get(request_id)
if done is not None:
return done # the gate retried; hand back the same ticket
placement = self.assigner.assign(self.levels, size)
if placement is None:
raise LotFull(f"no spot for {size.name}")
ticket = Ticket(uuid.uuid4().hex, plate, placement.spot, placement.level, now)
self._open[ticket.id] = ticket
self._by_request[request_id] = ticket
return ticket
def unpark(self, ticket_id: str, now: float) -> Decimal:
with self._lock:
ticket = self._open.pop(ticket_id) # KeyError here == the lost-ticket case
ticket.level.release(ticket.spot)
return self.rate_card.price(ticket.entry_ts, now)
The ticket id is a UUID, not f"T-{plate}-{int(now)}". Second-resolution timestamps collide when the same plate re-enters quickly, and the collision does not just duplicate an id, it overwrites the open ticket, so the first spot never gets released and the lot leaks capacity until someone restarts it.
park takes a request_id because entry gates retry on network timeouts, and a retry that assigns a second spot to the same driver becomes a support call. That map needs eviction and needs scoping to the entry attempt: a replay an hour later should be rejected, not handed back a ticket that has already closed.
The lot lock covers all of park, assignment included. Level keeps its own lock because it is usable on its own, so the nesting order is lot then level and never the reverse, which is why it cannot deadlock. unpark deliberately drops the lot lock before releasing the spot, which leaves a window where the spot sits in neither _open nor a heap and a concurrent park can see a full lot that is about to have room. Naming that gap and calling it acceptable is a better answer than pretending two locks compose into one atomic step.
The real cost is that every park in the garage serializes on one lock. Four gates in one process at 500 spots, that is free. Two app servers, and it stops working entirely.
Money lives in a rate card, and the failure paths are requirements
Asked at BlackRock — Design a parking lot system (premium, prompt included) Design a lot running several entry and exit gates that issues a ticket on the way in and charges on the way out based on time parked, possibly varying by vehicle or spot type. The prompt then asks for the ugly paths on purpose: a driver who lost their ticket, a payment that fails at the barrier, and gates that must keep issuing and accepting tickets while part of the system is down.
Pricing first, because it is the piece candidates most often bolt onto the wrong class.
import math
from decimal import Decimal
from typing import Protocol
class RateCard(Protocol):
def price(self, entry_ts: float, exit_ts: float) -> Decimal: ...
class TieredRate:
"""First hour at one price, each extra hour cheaper, capped per day,
with an optional free grace window at the start of the stay."""
def __init__(self, first_hour: Decimal, extra_hour: Decimal,
daily_cap: Decimal, grace_seconds: int = 0):
self.first_hour = first_hour
self.extra_hour = extra_hour
self.daily_cap = daily_cap
self.grace_seconds = grace_seconds
def price(self, entry_ts: float, exit_ts: float) -> Decimal:
seconds = max(0.0, exit_ts - entry_ts)
if seconds <= self.grace_seconds:
return Decimal("0.00")
hours = math.ceil(seconds / 3600) # any part-hour bills as a full hour
full_days, rem = divmod(hours, 24)
total = self.daily_cap * full_days
if rem:
total += min(self.first_hour + self.extra_hour * (rem - 1), self.daily_cap)
return total
O(1) time and space. Use Decimal rather than float, because a lot that bills 0.1 + 0.2 at volume eventually gets audited.
TieredRate subclasses nothing. Protocol is structural, so implementing price is enough; in Java this is an interface and an implements clause.
Two rounding rules hide in those seven lines and both are requirements rather than implementation details. math.ceil means a 61-minute stay bills two hours. And this grace window is all-or-nothing: leave inside fifteen minutes and you owe nothing, stay sixteen and you are billed from entry. The other reading, billing from the end of the grace window, is a one-line change and a completely different product. Ask which one before writing either.
The lost ticket is not an exception, it is a feature with a price. You need lookup of the open stay by plate, a policy charge for unknown entry time (the daily cap is the usual answer), and an audit record of the override. In the code above it is the KeyError branch in unpark, and interviewers hand it to you specifically to see whether you noticed the unhandled path.
Payment failure is a question about ordering, and it is visible in the code if you look. unpark releases the spot and then prices the stay, so a card that declines at the barrier declines after the spot is already back in the heap. Does the barrier stay down until settlement, or does the car leave with a balance attached to the plate? Both ship in real garages. Pick one out loud, because the answer decides whether unpark is allowed to fail after it has already mutated state.
Two cars, one spot
Asked at Amazon — Design a scalable parking lot system Take the same lot to production: many levels, several entry and exit gates per lot, a fleet of lots under one system, live availability, ticketing, payments and pricing rules. The constraint that changes the design is that the gates keep admitting cars under concurrent load, so entry has to stay correct when two of them reach for the last spot at the same instant.
Here is the bug that shows up at minute 35:
# Wrong.
spot = level.find_free_spot(size) # read
if spot is None:
raise LotFull()
spot.occupied = True # write
Two threads read the same free spot, both mark it occupied, both print a ticket. One driver ends up boxed in and the garage gets a phone call.

The locks in the previous section fix this inside one process and do nothing across two. With gates on two servers, the claim has to be atomic where the state actually lives:
UPDATE spot
SET status = 'OCCUPIED',
ticket_id = :ticket_id
WHERE id = (
SELECT id
FROM spot
WHERE lot_id = :lot_id
AND status = 'FREE'
AND size = ANY(:fitting_sizes)
-- This ORDER BY *is* the assignment policy, and it has to match
-- LowestLevelFirst: lowest level, smallest fitting size, then nearest.
-- array_position ranks by the preference order inside :fitting_sizes,
-- so a motorcycle takes a motorcycle spot before a compact one. Drop
-- that term and you get nearest-fitting-spot-regardless-of-size, which
-- is a different product decision, not a refactor.
ORDER BY level_index,
array_position(:fitting_sizes, size),
distance
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, level_index;
There is the seam again in a different language: the strategy object became an ORDER BY clause.
SKIP LOCKED is the phrase to know. Without it, two gates chasing the same row queue behind each other and the lot serializes at peak. With it, the loser steps over the locked row and claims a different spot.
What it does not give you is a trustworthy "full" signal, and getting this wrong is a production bug rather than an interview nitpick. Under READ COMMITTED, LIMIT 1 combined with FOR UPDATE can return zero rows while dozens of spots are free: the planner picks one candidate, another transaction commits a change to it first, Postgres re-evaluates the WHERE against the updated row version, the candidate drops out, and LIMIT 1 left no second candidate to fall through to. Zero rows means retry. Run the statement a bounded number of times and raise LotFull only after a retry also comes back empty, because under contention there is no cheap exact answer and "full" is allowed to be slightly stale.
| Approach | Safe across servers | Failure mode | Use when |
|---|---|---|---|
| Read the field, then set it | No | Double-booked spot, silently | Never |
| One lock around the whole claim | No | Serializes the garage; breaks on a second app server | Single-process interview default |
SELECT … FOR UPDATE SKIP LOCKED | Yes | Empty result can mean contention, not full; needs retry and a txn per park | Multiple gates, one database |
UPDATE … WHERE status='FREE', check rowcount | Yes | Retry storms as the last few spots go | High volume, low contention |
| Atomic pop from a Redis free list | Yes | Needs reconciliation if the app dies mid-park | Very high gate throughput |
| Serialize assignment through one worker | Yes | Single point of failure, head-of-line blocking | Complex fairness or reservation rules |
Reservations are worth pricing honestly against both of those models, because the in-memory design is the one that pays. Occupancy there is heap membership: a spot is free if and only if it is sitting in Level._free. So a hold has to pop the spot out, park it in a held map keyed by reservation id, and expiry has to push it back with its original (distance, id, spot) key or the ordering silently rots. That is a new field on Level, a sweeper job, and two more paths that can leak a spot. The database version gets off far cheaper, because status is already a column: RESERVED is one more enum value, WHERE status = 'FREE' keeps telling the truth, and a job flips stale holds back. If reservations are on the table from the start, model state explicitly and accept a slower "find nearest".
Peak occupancy is a sweep line, until the log stops being clean
Asked at Amazon — Compute peak parking lot occupancy intervals You get raw gate logs as
[carId, time, event]rows in arbitrary order, where event is entry or exit, and exits at a given timestamp count before entries at that same timestamp. Part one asks for the highest simultaneous occupancy. Part two asks for every maximal half-open window[start, end)where the count holds at that maximum. Part three breaks the exit scanner: some exits are simply missing, no car may stay longer than two hours, and you have to find the peak that the unknown departures could produce.
Once the interviewer asks "what was peak occupancy yesterday", the round has stopped being OOD. This is a sweep line over the entry and exit log:
def peak_occupancy(log: list[list]) -> int:
"""log: rows of [car_id, timestamp, 'entry' | 'exit'], in any order."""
deltas = [(ts, -1 if kind == "exit" else 1) for _car_id, ts, kind in log]
deltas.sort() # at equal t, -1 sorts before +1: the leaving car frees the spot
best = cur = 0
for _, delta in deltas:
cur += delta
best = max(best, cur)
return best
O(n log n) time, O(n) space, and the sort tie-break is the whole subtlety. Whether a car leaving at noon and one arriving at noon overlap depends on whether the interval is closed at both ends, and this question pins it down: exits land first, so (t, -1) must sort ahead of (t, +1). Tuple comparison gives that for free.
That function answers part one and nothing more. Part two wants the maximal windows, which is the same sweep with the run boundaries recorded rather than a running max, and it can return several disjoint intervals.
Part three is the one worth thinking about before you write code. Exits go missing, and each affected car's true departure is unknown inside (entry, entry + 2], chosen to make the peak as large as possible. The interval each car occupies is [entry, exit), so stretching any car's exit later can only raise occupancy at every instant, never lower it. Set every missing exit to entry + 2, the latest the policy allows, and run the same sweep. The adversarial version collapses into the clean one, and finding that is the actual test.
The general lesson survives outside this question. Scanner data is not clean, and a sweep that trusts it drifts negative on an orphan exit and stays permanently inflated on a duplicate entry. Anything running against production logs reconciles per car id first and decides what an unmatched event means before it sorts anything.
Practice these on PracHub
Start with the base object model. Design an object-oriented parking lot system (Uber) is the one to do cold with a timer, and write your clarifying questions down before a single class. It states the adjacency requirement in one sentence that is very easy to read past. Design a Parking Garage Object Model (Uber) removes the API surface and leaves only entity boundaries, which helps if you catch yourself hiding design decisions behind endpoint names. Prompts open on both, model solutions premium.
Then make the model survive a second shape. Design a multi-level parking system (DocuSign) asks the LowestLevelFirst decision directly, then hands you buses that need contiguous indexes in a row. Prompt open, solution premium.
Then take it to production. Design a scalable parking lot system (Amazon) means multiple gates, shared state and idempotent entry, so the SQL and the comparison table above are exactly what it is probing. Prompt open, solution premium.
For the algorithmic half, Compute peak parking lot occupancy intervals (Amazon) runs in a graded console. Read all three parts before writing anything, since part one is the function above and part three is where a plain sweep stops being the answer. Free, prompt and console both.
Three more are gated end to end, prompt included, for subscribers. Design a Parking Lot (Uber) is the plain base version, set in an AI-assisted coding round. Design a parking lot system (BlackRock) reframes the same core around gates, lost tickets and partial outages, which is a fair test of whether your model survives somebody else's vocabulary. Build a React Parking Lot Manager (Uber) moves the domain into a component tree, where optimistic updates put the race on screen.
FAQ
How long should I spend on requirements before drawing classes?
Four to six minutes of a 45-minute round. Cover gates, vehicle and spot compatibility, assignment policy, pricing, and reservations. If the interviewer answers all five in a sentence each, you have bought yourself a design that will not need rewriting when they add a constraint at minute 35.
Should I write real code or just draw the class diagram?
Ask, because it varies more than candidates expect. Most low-level design rounds want compilable code for the core methods and diagram-level detail everywhere else. A safe default is a full diagram plus working implementations of park, unpark and the pricing function, since those hold all the interesting decisions.
Do I need to handle concurrency if the interviewer didn't ask?
Raise it, then ask whether to implement it. Saying "with two entrances, find_free_spot followed by mark_occupied is a race, so I'd make the claim one atomic step" takes ten seconds and is often the difference between hire and lean-hire. Implementing distributed locking unprompted can eat the time you needed for the class model.
Which design patterns are worth naming in a parking lot design?
Strategy, for spot assignment and pricing, since both get changed on you mid-interview. State, if reservations come up. Avoid volunteering Singleton for ParkingLot: it is the stock answer, it makes the class untestable, and interviewers who ask about it are usually checking whether you will push back.
How do I handle a lost ticket?
Treat it as a requirement rather than an exception. You need lookup of the open stay by license plate, a policy price for unknown entry time (usually the daily cap), and an audit record of the override. In the code above it is the KeyError path in unpark, and interviewers hand it over specifically to see whether you spotted the unhandled branch.
Is parking lot design still a common interview question?
Yes, particularly at companies running a separate low-level design round. Uber, Amazon, DocuSign and BlackRock all appear in the question sets above. It persists because it is small enough to finish in 45 minutes and open-ended enough that two candidates never produce the same design.
Comments (0)