A unit turn board is one query over one table: every open turn, its current status, and how many days it has sat there. The build has two non-negotiables. Status must be a fixed set of values that can only move along legal edges (notice given, move-out inspection, make-ready, market ready, leased), stored as data rather than convention, or the board rots into free text within a quarter. And every status change must stamp a timestamp, because days-in-stage is the number the whole board exists to show: a $1,950 per month unit loses about $64 for every day it sits empty, so shaving a 21-day turn to 12 days across 40 annual turns is worth roughly $23,000 a year in rent alone.
Every multifamily operation tracks turns somewhere: a whiteboard in the maintenance shop, a shared spreadsheet with a Status column, sticky notes on a leasing office monitor. The tracking fails the same way everywhere. The status column drifts into a dozen spellings, nobody records when a unit entered its current stage, and the one question that matters, "which stage is eating our days," cannot be answered without an archaeology project. This guide builds the fix as a small internal tool on our published rental property data model, using its unit_turn, unit_turn_task, and unit_turn_transition tables. It is the apartment sibling of our hotel deep clean rotation scheduler: same philosophy, different clock. A hotel room turns in hours; an apartment turns in days, and every one of those days is priced.
The turn is a state machine, so store it as one
A turn is not a to-do item. It is a process with five distinct states, and a unit occupies exactly one of them at any moment:
notice_givenResident gave notice; clock startsmove_out_inspectionKeys back, condition documentedmake_readyRepairs, paint, clean, final walkmarket_readyGated: needs a passed final walkleasedNext lease signed; turn closesThe naive build stores that status as a text column and trusts the team to type it consistently. They will not. Within a quarter the column holds Make Ready, make-ready, MR, and painting, and every report you write needs a translation layer that somebody has to maintain forever. Worse, free text permits impossible histories: a unit that jumps from notice straight to market ready with no inspection ever recorded, and no way to notice at the time. The reference schema's fix goes one step past an enum: nobody ever types the status at all. The turn stores milestone facts, the date the notice arrived, the date the keys came back, the date make-ready started, and the status is a generated column projected from whichever facts exist:
-- From the reference schema's unit_turn table. 'MR' and 'painting'
-- are now unrepresentable: the status is computed, never typed.
status text GENERATED ALWAYS AS (
CASE
WHEN new_lease_id IS NOT NULL THEN 'leased'
WHEN market_ready_on IS NOT NULL THEN 'market_ready'
WHEN make_ready_started_on IS NOT NULL THEN 'make_ready'
WHEN moved_out_on IS NOT NULL THEN 'move_out_inspection'
ELSE 'notice_given'
END
) STORED
-- The legal edges ship as seeded rows, each naming the fact
-- whose arrival triggers it. Runnable as-is:
SELECT from_status, to_status, trigger_event
FROM unit_turn_transition
ORDER BY from_status;
This inverts how most trackers work, and the inversion is the whole trick: you never set the status, you record a fact, and the status follows. The unit_turn_transition table documents the legal edges as seeded rows, each carrying a trigger_event naming the fact that fires it, and the schema's CHECK constraints make out-of-order facts fail at write time (make-ready cannot start before the keys are back, market-ready cannot precede make-ready). The milestone dates also do the second job for free: the difference between "unit 12B is in make-ready" and "unit 12B has been in make-ready for 9 days" is just the age of the latest non-null milestone, which is the difference between a list and a board.
What a slow turn actually costs
Before writing another line, run this worksheet, because it is the argument for building anything at all. Vacancy loss is invisible on a P&L in a way a paint invoice is not: no bill arrives for the rent a vacant unit did not earn. The math is one division. A unit renting at $1,950 per month earns $23,400 a year, which is $64 per calendar day. Every day of the turn after move-out and before the next move-in is a day you pay that number.
The example numbers are deliberately ordinary. According to Zillow's Observed Rent Index (zillow.com), the typical U.S. asking rent stood at $1,965 in June 2026, so a typical unit's vacant day already costs about $65. The 40-turn assumption is ordinary too: Zego's Resident Experience Management Report (gozego.com) put the average multifamily retention rate at 60%, the highest it had measured since it began tracking in 2021, which still means four in ten units turn every year.
And lost rent is only one slice of the total. The same Zego report, surveying property management companies of 250 or more units, found the cost of replacing a single resident holds at almost $4,000 once repairs, marketing, concessions, and vacancy loss are counted; Multifamily Dive's coverage of that research (multifamilydive.com) pegged the figure at $3,872 across 630 surveyed property managers. The National Apartment Association's turnover cost analysis (naahq.org) frames the per-turn range at $1,000 to $5,000 depending on the level of upgrades, and works an example in which trimming one move-out per month from a 225-unit community saves over $20,000 a year. We compile the full landscape of these figures, each verified at its source, in our apartment turnover cost statistics research page. The board does not shrink the make-ready invoice; what it shrinks is the days, and the days are the biggest line nobody bills you for.
The build, step by step
Everything below runs against the reference schema from our rental property data model, which defines property, unit, tenant, lease, and the turn tables used here. Column names in the snippets follow that schema; if you are building from scratch, fetch the schema file first (step 6 hands that job to a coding agent).
Open the turn the moment notice lands
The turn clock starts at notice, not at move-out. The days between notice and key return are your pre-leasing and scheduling window: vendors get booked, the listing goes live, the move-out inspection gets on a calendar. A turn opened at move-out has already wasted its cheapest days. So the trigger is the lease event:
-- Notice recorded on the lease: a turn opens. Status is not
-- in the column list; with no milestones set it computes to
-- notice_given on its own.
INSERT INTO unit_turn
(unit_id, vacating_lease_id, notice_id, notice_received_on, expected_move_out_on)
SELECT l.unit_id, l.id, n.id, n.served_on, n.effective_on
FROM lease l
JOIN notice n ON n.lease_id = l.id
AND n.notice_type = 'tenant_notice_to_vacate'
WHERE l.id = :lease_id;
One open turn per unit. Add a partial unique index on unit_id where new_lease_id IS NULL (an open turn is one that no new lease has closed). A second notice on the same unit is a data problem you want surfaced at insert time, not discovered on the board.
Sequence the tasks: inspect, repair, paint, clean, walk
Inside the turn, work has a fixed order, and the order is not preference, it is physics. The move-out inspection comes first because it defines the repair scope and documents chargeable damage while the evidence is fresh (in this schema it is the turn's own milestone, recorded on unit_turn.move_out_inspection_id, so the task chain covers what happens after the keys are back). Repairs precede paint because patched drywall needs painting over. Paint precedes cleaning because painters make dust. Cleaning precedes the final walk because the walk verifies the finished product. Encode the chain as task rows ordered by seq; a task is workable only when everything with a lower seq is done:
-- Opening a turn generates its task chain. Gaps in seq leave room
-- for inserts (carpet replacement between repairs and paint).
INSERT INTO unit_turn_task (unit_turn_id, seq, task_type, title) VALUES
(:turn_id, 10, 'repair', 'Repairs from the move-out walk'),
(:turn_id, 20, 'painting', 'Paint: patched walls and ceilings'),
(:turn_id, 30, 'cleaning', 'Deep clean to move-in standard'),
(:turn_id, 40, 'rekey', 'Rekey locks and reset codes'),
(:turn_id, 50, 'final_walk', 'Final walk with photos');
Two boundaries keep this table honest. Work discovered during the turn that exceeds turn scope, a failed compressor, a slab leak, becomes a maintenance_request row linked to the unit, not a fifth task squeezed into the chain; the turn tracks readiness, not capital projects. And the move-out inspection's findings do double duty: its inspection_item rows are the evidence your deposit_deduction rows cite against the security_deposit, which is a whole discipline of its own; we cover it in the move-out inspection and deposit system guide.
Advance the turn by recording facts, not statuses
There is no "set status" statement anywhere in this build. Advancing the turn means writing the milestone fact named in the transition's trigger_event, and the generated column moves on its own. The schema's CHECK constraints are the guard rails: a fact recorded out of order is rejected at write time instead of silently writing fiction:
-- Keys returned: record the fact. Status computes to
-- move_out_inspection on its own.
UPDATE unit_turn
SET moved_out_on = CURRENT_DATE,
move_out_inspection_id = :inspection_id
WHERE id = :turn_id;
-- Crew on site: make-ready begins.
UPDATE unit_turn
SET make_ready_started_on = CURRENT_DATE
WHERE id = :turn_id;
-- And the guard rail, demonstrated on purpose: run the same
-- statement on a turn whose keys never came back, and Postgres
-- itself refuses the row. This one is supposed to fail:
UPDATE unit_turn
SET make_ready_started_on = CURRENT_DATE
WHERE id = :turn_id; -- a turn still in notice_given
-- ERROR: new row for relation "unit_turn" violates check
-- constraint (make_ready requires moved_out_on)
Gate market_ready on a verified final walk, structurally
"Market ready" is a claim to the leasing team: show this unit, sign this lease, promise this date. The claim should be impossible to make until somebody has walked the unit and passed it, and "impossible" belongs in the write, not in a training document. Guard the milestone update so it lands only when every task in the chain, the final walk included, is verified or deliberately skipped:
-- make_ready to market_ready has an extra condition: proof.
-- 0 rows updated = something in the chain is not verified yet.
UPDATE unit_turn ut
SET market_ready_on = CURRENT_DATE
WHERE ut.id = :turn_id
AND ut.make_ready_started_on IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM unit_turn_task t
WHERE t.unit_turn_id = ut.id
AND t.status NOT IN ('verified', 'skipped'));
The final walk itself is the chain's last unit_turn_task (task type final_walk), and flipping it to verified is what unlocks the milestone; record the walk as a routine inspection on the unit with inspection_item children, one per checklist line, each ideally carrying a photo. That photo set is worth more than the checkbox: it is the documented condition of the unit at its best, timestamped, which is the baseline every future damage conversation gets measured against. Reviewing those photos at scale is the part that breaks down when a human has to do it for every turn, and it is exactly the review RapidEye automates for operators; the walk photos your team already takes become the evidence layer instead of a folder nobody opens.
Write the board query
With statuses computed and every stage anchored to a milestone date, the board is two queries. The age of the current stage is the age of the latest non-null milestone, which COALESCE walks in the same order the status projection does. The first query is the board itself, every turn in flight with its age in stage; the second is the management view, which stage is the bottleneck this month:
-- The board: every open turn, aged. Sort oldest-first per column.
SELECT p.name AS property, u.unit_number, ut.status,
CURRENT_DATE - COALESCE(ut.market_ready_on, ut.make_ready_started_on,
ut.moved_out_on, ut.notice_received_on)
AS days_in_stage
FROM unit_turn ut
JOIN unit u ON u.id = ut.unit_id
JOIN property p ON p.id = u.property_id
WHERE ut.new_lease_id IS NULL
ORDER BY ut.status, days_in_stage DESC;
-- The bottleneck: which stage is eating the portfolio's days.
SELECT status, COUNT(*) AS turns,
ROUND(AVG(CURRENT_DATE - COALESCE(market_ready_on, make_ready_started_on,
moved_out_on, notice_received_on)), 1) AS avg_days
FROM unit_turn
WHERE new_lease_id IS NULL
GROUP BY status
ORDER BY avg_days DESC;
The bottleneck query is the one that changes behavior. A wall of individual late units invites individual excuses; an average that says make-ready holds units 3 times longer than any other stage says hire a second painter or fix vendor scheduling. Once the turn closes, the vacancy it cost is also auditable after the fact: the gap between the last rent charge on the old lease and the first on the new one in rent_ledger_entry is the realized days-vacant, priced in actual dollars, per turn.
Render the board, and age everything
The UI is five columns, one per status, one card per open turn, and the single most important pixel on it is the age chip. Give each stage a day budget (2 days in move-out inspection, 7 in make-ready, 5 in market ready before the price conversation starts) and color the chip against it. Here is the whole thing, with 16 units in flight on illustrative data:
notice_given4move_out_inspection3make_ready5market_ready3leased1Read the mock the way a Tuesday morning meeting would. Make-ready holds 5 of 16 turns and both of its red cards; that is the bottleneck query rendered as color, and the question for the room is what 11C and 23A are waiting on. 17A is the quieter alarm: a unit that has been market ready for 13 days is not a maintenance problem, it is a pricing or marketing problem, and no amount of turn discipline fixes it. A board that ages every column, not just make-ready, is what catches that distinction.
Hand it to your coding agent
The schema, the rules, and the queries above are the spec. The prompt below points a coding agent at the reference schema file so it builds on the exact tables instead of inventing its own. Paste it into Claude, Cursor, or whatever you build with:
Build a unit turn board for my rental portfolio.
First, fetch and read the reference data model:
https://rapideyeinspections.com/blog/rental-property-data-model/schema.sql
Use its property, unit, tenant, lease, notice, unit_turn,
unit_turn_task, unit_turn_transition, inspection, and
inspection_item tables, with the exact column names in that file.
Hard rules:
- unit_turn.status is a GENERATED column projected from milestone
dates (notice_received_on, moved_out_on, make_ready_started_on,
market_ready_on, new_lease_id). Never write status; write the
milestone fact and let the status follow. The seeded
unit_turn_transition rows document the legal edges and the
trigger_event naming each fact.
- Opening a turn (a tenant_notice_to_vacate row lands in notice)
creates the unit_turn plus its unit_turn_task chain in seq
order: repairs, painting, cleaning, rekey, final_walk. A task
is workable only when every lower-seq task is done.
- market_ready_on may only be set when make_ready_started_on is
set and every unit_turn_task is verified or skipped, the
final_walk task included. Enforce it in the update, never in
the UI. Surface the schema's CHECK violations as readable
errors instead of stack traces.
- Days-in-stage and days-vacant are always derived from the
milestone dates, never typed by a person.
- One open turn per unit (new_lease_id IS NULL), enforced by a
partial unique index.
- All timestamps UTC.
Phase 1: a five-column board (one column per status) with unit
cards aged against per-stage day budgets (green under, amber at,
red over), a bottleneck header showing count and average days per
stage, a one-tap task completion flow that works from a phone
browser, and a per-turn days-vacant cost counter priced at the
unit's monthly rent times 12 divided by 365. Seed 3 properties,
60 units, and 16 in-flight turns with randomized history so I can
demo it immediately.
The reference schema the prompt fetches is documented table by table in the rental property data model. Building on it means this board, your deposit system, and whatever you build next all agree on what a unit and a lease are, instead of becoming three more systems that need reconciling.
Quick FAQ
What is a unit turn board?
A live view of every apartment between one resident and the next: which stage each unit is in (notice given, move-out inspection, make-ready, market ready, leased), how many days it has sat there, and which stage is the current bottleneck. Built on the rental property data model, it is one query over the unit_turn table, so it is always derived from data rather than hand-updated.
How much does one vacant day cost on an apartment unit?
Monthly rent times 12, divided by 365. A $1,950 per month unit loses about $64 for every empty day. At the national level, Zillow's Observed Rent Index put typical U.S. asking rent at $1,965 in June 2026, roughly $65 per vacant day for a typical unit.
Why track turn status in a database instead of a spreadsheet column?
Because a free-text status column rots: within a quarter it holds four spellings of make-ready and no query can group them. Storing milestone dates and generating the status from them makes misspellings and out-of-order histories unrepresentable, and keeps days-in-stage and bottleneck reports computable straight from the dates. Changing the process later is editing data, not rewriting code.
What order should make-ready tasks run in?
Inspect, repair, paint, clean, final walk. The inspection defines repair scope and documents chargeable damage first; repairs precede paint because patches need covering; paint precedes cleaning because painters make dust; cleaning precedes the walk because the walk verifies the finished product. Sequence the tasks by number and treat a task as workable only when everything before it is done.
Sources
Sources are named at the publisher level with their root domain, rather than linked or titled; every figure is verifiable at the named source.
- Zego, Resident Experience Management Report and unit turnover researchgozego.com
- Multifamily Dive, reporting on multifamily turnover costs, 2023multifamilydive.com
- National Apartment Association, turnover cost analysisnaahq.org
- Zillow, Observed Rent Index rental market reporting, 2026zillow.com

