A property management database schema has to hold four different things at once: a portfolio of buildings, the people renting them, the agreements between the two, and the money and repairs that follow. This one models all four in 13 tables with 13 foreign key relationships, 2 link tables and 5 context views, on PostgreSQL with snake_case naming. Every table below is real: the columns, types, keys, unique constraints and the 10 check constraints are read straight out of the diagram, which you can open and edit at the end.
What tables does a property management database need?
properties
Real estate properties managed by the system.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
address | TEXT | no | unique (properties_address_key) | |
name | TEXT | no | ||
property_type | TEXT | no | check: residential, commercial, industrial, mixed_use | |
total_units | INTEGER | no | ||
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
units
Individual rental units within a property.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
property_id | BIGINT | FK | no | to properties.id |
unit_number | TEXT | no | ||
bathrooms | INTEGER | no | ||
bedrooms | INTEGER | no | ||
square_feet | INTEGER | no | ||
rent_amount | NUMERIC | no | ||
status | TEXT | no | check: vacant, occupied, maintenance, reserved | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
amenities
Available amenities for properties.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
name | TEXT | no | unique (amenities_name_key) | |
description | TEXT | no | default '' | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
properties_amenities
Junction table mapping properties to amenities.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
amenity_id | BIGINT | PK, FK | no | to amenities.id |
property_id | BIGINT | PK, FK | no | to properties.id |
tenants
Registered tenants.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
email | TEXT | no | unique (tenants_email_key) | |
phone_number | TEXT | no | unique (tenants_phone_number_key) | |
first_name | TEXT | no | ||
last_name | TEXT | no | ||
emergency_contact | TEXT | no | default '' | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
tenant_documents
Documents submitted by tenants.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
tenant_id | BIGINT | FK | no | to tenants.id |
file_url | TEXT | no | ||
document_type | TEXT | no | check: id_proof, income_proof, lease_agreement, background_check, other | |
uploaded_at | TIMESTAMPTZ | no | default NOW() | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
leases
Lease agreements for rental units.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
unit_id | BIGINT | FK | no | to units.id |
start_date | DATE | no | ||
end_date | DATE | no | ||
monthly_rent | NUMERIC | no | ||
security_deposit | NUMERIC | no | ||
status | TEXT | no | check: draft, pending, active, expired, terminated | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
lease_tenants
Links a lease to the tenants named on it. Singular lease in the name is deliberate: this table crosses a context boundary, so it is not shaped like the plain many-to-many table above.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | surrogate key |
lease_id | BIGINT | FK | no | to leases.id |
tenant_id | BIGINT | FK | no | to tenants.id |
Unique on (lease_id, tenant_id) as lease_tenants_lease_id_tenant_id_key.
invoices
Invoices generated for rental payments.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
lease_id | BIGINT | FK | no | to leases.id |
due_date | DATE | no | ||
total_amount | NUMERIC | no | ||
status | TEXT | no | check: pending, paid, partially_paid, overdue, cancelled | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
payments
Transactions completed against invoices.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
invoice_id | BIGINT | FK | no | to invoices.id |
amount | NUMERIC | no | ||
payment_date | DATE | no | ||
payment_method | TEXT | no | check: credit_card, debit_card, bank_transfer, cash, check | |
status | TEXT | no | check: pending, completed, failed, refunded | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
maintenance_requests
Issues reported by tenants for units.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
tenant_id | BIGINT | FK | no | to tenants.id |
unit_id | BIGINT | FK | no | to units.id |
description | TEXT | no | ||
priority | TEXT | no | check: low, medium, high, urgent | |
status | TEXT | no | check: submitted, in_progress, scheduled, completed, cancelled | |
title | TEXT | no | ||
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
work_orders
Work orders created to fulfill maintenance requests.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
maintenance_request_id | BIGINT | FK | no | to maintenance_requests.id |
vendor_id | BIGINT | FK | no | to vendors.id |
actual_cost | NUMERIC | no | default 0 | |
estimated_cost | NUMERIC | no | default 0 | |
scheduled_date | DATE | yes | the only nullable column in the schema | |
status | TEXT | no | check: assigned, in_progress, completed, cancelled, on_hold | |
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
vendors
Contractors performing maintenance work.
| Column | Type | Key | Nullable | Notes |
|---|---|---|---|---|
id | BIGINT | PK | no | |
email | TEXT | no | unique (vendors_email_key) | |
phone_number | TEXT | no | unique (vendors_phone_number_key) | |
company_name | TEXT | no | ||
contact_name | TEXT | no | ||
created_at | TIMESTAMPTZ | no | default NOW() | |
updated_at | TIMESTAMPTZ | no | default NOW() |
How the tables relate
Every relationship in this schema is one-to-many with a cardinality of zero or many on the child side, which is to say a property may have no units yet, an invoice may have no payments against it, and a maintenance request may not have produced a work order. None of the 13 foreign key columns is nullable, so a child row cannot exist without its parent.
| Parent | Child | Foreign key | Cardinality |
|---|---|---|---|
properties | units | units.property_id | 1:N, zero or many |
properties | properties_amenities | properties_amenities.property_id | 1:N, zero or many |
amenities | properties_amenities | properties_amenities.amenity_id | 1:N, zero or many |
tenants | tenant_documents | tenant_documents.tenant_id | 1:N, zero or many |
units | leases | leases.unit_id | 1:N, zero or many |
leases | lease_tenants | lease_tenants.lease_id | 1:N, zero or many |
tenants | lease_tenants | lease_tenants.tenant_id | 1:N, zero or many |
leases | invoices | invoices.lease_id | 1:N, zero or many |
invoices | payments | payments.invoice_id | 1:N, zero or many |
units | maintenance_requests | maintenance_requests.unit_id | 1:N, zero or many |
tenants | maintenance_requests | maintenance_requests.tenant_id | 1:N, zero or many |
maintenance_requests | work_orders | work_orders.maintenance_request_id | 1:N, zero or many |
vendors | work_orders | work_orders.vendor_id | 1:N, zero or many |
The two many-to-many pairs in the schema are properties to amenities and leases to tenants. Neither is a direct relationship: each is resolved through a link table that appears in the list above as two separate one-to-many rows. The two link tables are not built the same way, and the difference is the subject of the design decisions below.
The five context views
A context view is a focused subset of the main diagram, showing one part of the model while the main view remains the single source of truth. This schema is divided into five of them, and the same five groups are drawn as coloured boxes on the diagram itself. Seven of the 13 relationships stay inside a single view and four cross from one view into another. The remaining two belong to lease_tenants, which is assigned to no view at all.
properties
Contains properties, units, amenities and properties_amenities. All three of its relationships stay inside the view: a property has many units, and a property is linked to many amenities through the junction table. Nothing in this view points outward. It is the only view of the five that is closed in that sense, which makes sense for a portfolio: buildings exist before anyone rents them.
tenants
Contains tenants and tenant_documents, joined by a single relationship. Everything else that concerns a tenant lives in another view and reaches back into this one, so tenants is the most referenced table in the schema: lease_tenants and maintenance_requests both hold a tenant_id.
leases
Contains leases and nothing else. Not one of its relationships stays inside the view: leases.unit_id reaches into the properties view, invoices.lease_id reaches in from the payments view, and the link to tenants goes through a table that sits in no view. A lease is the most connected row in this model and the least self-contained, which is what a single-table view says out loud.
The table in no view
lease_tenants is not assigned to any of the five views, and that is the point of it. It holds a lease_id and a tenant_id, and its two parents live in different contexts, so filing it inside either the leases view or the tenants view would give one context ownership of a fact that belongs to both. Its name records the same thing: the first word is singular where a plain many-to-many table would be plural. On the diagram above it is drawn inside the orange leases box, which is a grouping on the canvas and not the same thing as view membership - the leases context view itself lists one entity.
payments
Contains invoices and payments, with one relationship between them and one crossing out through invoices.lease_id into the leases view. Money attaches to the agreement, not to the unit and not to the tenant, so an invoice is always reachable back to exactly one lease.
maintenance
Contains maintenance_requests, work_orders and vendors. Two relationships stay inside: a request produces work orders, and a vendor is assigned to work orders. Two cross out, both from maintenance_requests, one into the properties view via unit_id and one into the tenants view via tenant_id. This is the only view that reaches into two other views from a single table, which is what you would expect of a repair: it happens to a unit and it is reported by a person.
Counting foreign keys as they cross between groups like this is the same reading the Context Map applies to a whole schema, one level up from individual tables.
Design decisions
Why do the two link tables have different keys?
Both connect two parents and neither carries a payload, but they are built differently on purpose, and the names say which is which.
properties_amenities | lease_tenants | |
|---|---|---|
| Name | both words plural | first word singular |
| Primary key | composite (amenity_id, property_id) | surrogate id |
| Uniqueness of the pair | enforced by the primary key | enforced by a unique constraint |
| Crosses a context boundary | no, both parents sit in the properties view | yes, leases and tenants sit in different views |
properties_amenities is a plain many-to-many table. Its two columns are its key, a property is linked to an amenity once or not at all, and there is nothing more to say about the pairing.
lease_tenants records who is named on a lease, which is a fact belonging to two contexts at once. It takes a surrogate id so the pairing can be referenced as a row in its own right, and the pair is protected by lease_tenants_lease_id_tenant_id_key instead of by the primary key. That uniqueness is total here because both columns are NOT NULL: a nullable column inside a unique constraint would exempt those rows from it, and neither of these is nullable.
Why does a lease reach tenants through a link table?
A tenant_id column on leases would permit exactly one tenant per lease. Co-tenants are ordinary in residential renting, and a tenant signing a second lease later is equally ordinary, so the relationship is many-to-many in both directions and lease_tenants is what expresses it. The cost is that “who lives here” is a join rather than a column.
Why is scheduled_date the only nullable column?
Across roughly a hundred columns in 13 tables, work_orders.scheduled_date is the single one that permits NULL. That is a deliberate statement about the life of a work order: it is created and assigned to a vendor before anyone agrees on a date, so the absence of a date is a real state rather than missing data. Everywhere else the schema takes the opposite position, and the two TEXT columns that could plausibly be empty - amenities.description and tenants.emergency_contact - default to an empty string instead of NULL. Being deliberate about which columns admit NULL is what keeps a nullable column from quietly weakening the constraints around it.
Why are the status columns check constraints rather than enums or lookup tables?
There are 10 check constraints in this schema and every one of them fixes an allowed value set: four property types, four unit statuses, five document types, five lease statuses, five invoice statuses, five payment methods, four payment statuses, four maintenance priorities, five maintenance statuses and five work order statuses. Written as check constraints rather than as a Postgres enum type or a lookup table, the permitted values stay visible in the table definition and in the ERD, and Schemity renders them on the entity instead of hiding them behind a type name. The trade is that adding a sixth lease status needs a migration, and that these values cannot carry extra attributes the way a lookup table row can.
Why does leases have no property_id?
A lease points at a unit; the unit points at the property. The property is one hop away, so storing it on the lease as well would create a second path to the same fact, and two paths can disagree after an update. The same reasoning keeps payments attached to invoices rather than directly to leases.
Why do payments hang off invoices instead of leases?
Because one invoice can be settled by several transactions. invoices.status includes partially_paid, which only means something if more than one payment row can reference the same invoice, and the one-to-many from invoices to payments is what allows it. A payment table keyed to the lease could not express which charge a partial payment was against.
Why is total_units stored on properties?
This one is a deliberate denormalization rather than a constraint, and it is worth naming as such: properties.total_units holds a count that could be derived by counting units rows for that property. It is cheap to read and it will drift the first time a unit is inserted without the counter being updated. If you adopt this schema, either maintain it in a trigger or drop the column and count.
What changes in production
The schema above is complete as a model, and there are five things it does not yet carry that a deployed property management system will need.
No indexes are defined. PostgreSQL creates an index for a primary key and for a unique constraint, but it does not index foreign key columns automatically. Every one of the 13 foreign key columns here - units.property_id, leases.unit_id, invoices.lease_id, payments.invoice_id and the rest - will be scanned rather than looked up until you add one, and the joins in this schema are almost entirely parent-to-child.
No deletion behaviour is recorded. The diagram carries no ON DELETE rule on any relationship, so every foreign key falls back to NO ACTION. In practice that means a tenant with documents or lease rows cannot be deleted at all. Decide per relationship whether that is the intent, whether children should cascade, or whether the answer is a soft delete - there is no deleted_at column anywhere in this schema.
Money is NUMERIC with no precision or scale. rent_amount, monthly_rent, security_deposit, total_amount, amount, estimated_cost and actual_cost are all unconstrained NUMERIC, which in PostgreSQL stores whatever scale it is handed. Pin them to NUMERIC(12,2) before real currency reaches them.
There is no tenancy column. This schema models one operator’s portfolio. If a single deployment serves several management companies, an owning organisation column has to reach nearly every table, and that is a decision to make before data exists rather than after.
There is no audit trail of who did what. Every table carries created_at and updated_at as TIMESTAMPTZ defaulting to NOW(), which answers when a row changed, but no column answers by whom. Lease status and payment status are exactly the fields a dispute will ask that question about.
Open this schema in Schemity Lite
The diagram is public and openable: open the property management schema in Schemity Lite. Nothing is read-only about it - drag the entities into an arrangement that suits you, add the columns your version needs, and export the result as SQL, Mermaid, DBML or an image. If you would rather work against your own database rather than a browser copy, the desktop app reverse engineers a live PostgreSQL database into the same kind of diagram.
Frequently asked questions
What tables does a property management system need?
This schema uses 13: properties, units, amenities and the properties_amenities junction table for the portfolio; tenants and tenant_documents for the people; leases and the lease_tenants link table for the agreements; invoices and payments for the money; and maintenance_requests, work_orders and vendors for repairs.
How do you model a lease with more than one tenant?
Put a link table between them. Here lease_tenants holds a lease_id and a tenant_id under a unique constraint on the pair, so one lease can carry several co-tenants and one tenant can hold several leases over time. A tenant_id column on leases would allow only one tenant per lease.
Should rental status columns be check constraints, enums or lookup tables?
This schema uses check constraints. All 10 of them list their allowed values inline, for example units.status allows vacant, occupied, maintenance and reserved. That keeps the value set visible in the table definition and in the ERD, at the cost of needing a migration to add a new value.
Does a lease need a property_id as well as a unit_id?
No. A lease points at a unit, and the unit points at the property, so the property is reachable in one hop. Storing property_id on the lease as well would create a second path to the same fact and two paths can disagree.