
CSV Import for Property Management Software
The rent roll is the document a property manager lives by. It lists every unit in the portfolio, who lives there, what they pay and when their lease ends, and it gets pulled fresh for lenders, for owners and for the monthly review. The system a portfolio runs prints one on demand.
It prints one page. A line on that page mixes the building, the unit, the lease and the resident together, because a person reading it wants all four at once. A database keeps them apart.
The rent roll a portfolio sends holds 1,905 rows. Behind it sit 6 properties, 1,710 units, 1,573 leases and 1,680 residents. None of those four numbers is written anywhere on the page.
A property management platform keeps each of those four as its own record. Its customers export a rent roll out of the system they are leaving, and that file flattens all four onto one line. Moving those lines into your tables is customer data onboarding.
The schema belongs to you. The roll belongs to the portfolio manager at the
customer, who pulled it out of whatever ran the portfolio before. The one below
runs 1,905 rows over 14 columns, 270 KB of .csv, and every figure in this
article was read off a single import of it.
The four tables behind one row
A property owns units. A unit holds leases over time. A lease carries one resident or several. Each of those is a row in a different table, and each child row stores the id of the parent above it.
The order is published. Buildium tells its own customers to import rentals first and people second, because "after your properties and units have been entered into Buildium, you can begin to add your people". Its tenant template asks every row for a property and a unit number.
A rent roll carries all four levels on one line. The property name repeats down every row of that property, the unit repeats for every resident in it, and the lease dates repeat for every roommate.
The rent roll a portfolio sends
Eight rows out of it, in the spelling the export used.
| A | B | C | D | E | F | G | H | I | J | K | L | M | N | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Prop | Property | Property Address | Unit | SqFt | Beds | Baths | Status | Resident | Lease From | Lease To | Market Rent | Rent | |
| 2 | GRN | Greenfield Commons | 1400 Greenfield Road | A101 | 689 | 1 | 1 | Occupied | Villanueva, Nadim | [email protected] | 2025-11-08 | 2026-09-01 | 1,565.00 | $1,565.00 |
| 3 | GRN | GREENFIELD COMMONS | 1400 Greenfield Rd | A102 | 710 | 1 | 1 | Vacant-Ready | $1,535.00 | |||||
| 4 | GRN | Greenfield Commons | 1400 Greenfield Rd | A122 | 530 | 0 | 1 | Occupied | Beaumont, Valentina | [email protected] | 2024-08-21 | 2025-09-16 | $1,140.00 | 1,100.00 |
| 5 | GRN | Greenfield Commons | 1400 Greenfield Rd | A122 | 530 | 0 | 1 | Occupied | Radulescu, Quentin | [email protected] | 2024-08-21 | 2025-09-16 | $1,140.00 | 1,100.00 |
| 6 | RIV | Riverbend Terrace | 60 Riverbend Way | B403 | 704 | 1 | 1 | Occ | Ishikawa, Joaquin | [email protected] | 07/2024 | 2025-07-10 | $1,530.00 | $1,495.00 |
| 7 | RIV | Riverbend Terrace | 60 Riverbend Way | B403 | 704 | 1 | 1 | Occupied | Haddad, Wilhelmina | [email protected] | 2024-09-06 | 2025-03-30 | 1,530.00 | $1,520.00 |
| 1196 rows not shown | ||||||||||||||
| 1204 | HAW | Hawksmoor Court | 215 Hawksmoor Lane | C210 | 845 | 2 | 1 | Vacant | - | |||||
| 701 rows not shown | ||||||||||||||
| 1906 | WLB | Willowbank Mews | 8 Willowbank Drive | D118 | 612 | 1 | 1 | Occupied | Okafor, Selina | [email protected] | 2025-04-14 | 2026-04-13 | 1,285.00 | $1,285.00 |
1Prop,Property,Property Address,Unit,SqFt,Beds,Baths,Status,Resident,Email,Lease From,Lease To,Market Rent,Rent2GRN,Greenfield Commons,1400 Greenfield Road,A101,689,1,1,Occupied,"Villanueva, Nadim",[email protected],2025-11-08,2026-09-01,"1,565.00","$1,565.00"3GRN,GREENFIELD COMMONS,1400 Greenfield Rd,A102,710,1,1,Vacant-Ready,,,,,"$1,535.00",4GRN,Greenfield Commons,1400 Greenfield Rd,A122,530,0,1,Occupied,"Beaumont, Valentina",[email protected],2024-08-21,2025-09-16,"$1,140.00","1,100.00"5GRN,Greenfield Commons,1400 Greenfield Rd,A122,530,0,1,Occupied,"Radulescu, Quentin",[email protected],2024-08-21,2025-09-16,"$1,140.00","1,100.00"6RIV,Riverbend Terrace,60 Riverbend Way,B403,704,1,1,Occ,"Ishikawa, Joaquin",[email protected],07/2024,2025-07-10,"$1,530.00","$1,495.00"7RIV,Riverbend Terrace,60 Riverbend Way,B403,704,1,1,Occupied,"Haddad, Wilhelmina",[email protected],2024-09-06,2025-03-30,"1,530.00","$1,520.00"⋮1196 rows not shown1204HAW,Hawksmoor Court,215 Hawksmoor Lane,C210,845,2,1,Vacant,,,,,-,⋮701 rows not shown1906WLB,Willowbank Mews,8 Willowbank Drive,D118,612,1,1,Occupied,"Okafor, Selina",[email protected],2025-04-14,2026-04-13,"1,285.00","$1,285.00"A102 is empty from the resident column rightward, because nobody lives there.
The two A122 rows are roommates on one lease. The two B403 rows are two
leases on one unit, and the first of them carries a lease start with a month, a
year, and no day. C210 writes its market rent as a dash.
The roll names 1,680 people and the address each one lives at. Updog reads that file, matches it and checks it in the tab the portfolio manager opened, so your endpoint is the first machine to receive a row of it. Client-side and server-side import sets the two designs side by side.
What a lease looks like in your schema
Fourteen fields, and the status list is published. Fannie Mae's multifamily rent
roll template allows three values for Lease Status Type, Employee, Occupied
and Vacant, and it says a unit used as a model or a rental office is reported as
Vacant. The same template makes Market Comparable Rent Amount required and
caps Tenant Contract Rent Amount at 999,999.99.
import type { DataEditorColumn } from "@updog/data-editor";import { leaseFrom } from "./leaseFrom";import { propertyName } from "./propertyName";
const STATUS = ["Occupied", "Vacant", "Employee"];
export const columns: DataEditorColumn[] = [ { id: "propertyCode", title: "Property code", validators: [{ type: "required" }] }, propertyName, { id: "propertyAddress", title: "Property address", size: 220 }, { id: "unitNumber", title: "Unit number", validators: [{ type: "required" }], dependentFields: ["leaseFrom"], }, { id: "squareFeet", title: "Square feet", editor: { type: "number" }, validators: [{ type: "number", min: 0, decimalPlaces: 0 }], }, { id: "bedrooms", title: "Bedrooms", editor: { type: "number" }, validators: [{ type: "number", min: 0, decimalPlaces: 0 }], }, { id: "bathrooms", title: "Bathrooms", editor: { type: "number" }, validators: [{ type: "number", min: 0, decimalPlaces: 1 }], }, { id: "leaseStatus", title: "Lease status", editor: { type: "select", options: STATUS, enableCustomValue: false }, validators: [{ type: "oneOf", values: STATUS }], dependentFields: ["residentName"], }, { id: "residentName", title: "Resident name", size: 200, validators: [ { type: "function", fn: (value, row) => { const name = String(value ?? "").trim(); if (row.leaseStatus === "Vacant" && name) { return { level: "error", message: "A vacant unit carries no resident" }; } if (row.leaseStatus && row.leaseStatus !== "Vacant" && !name) { return { level: "error", message: "An occupied unit needs a resident" }; } return null; }, }, ], }, { id: "residentEmail", title: "Resident email", size: 240, validators: [{ type: "email" }] }, leaseFrom, { id: "leaseTo", title: "Lease to", editor: { type: "date" }, dependentFields: ["leaseFrom"] }, { id: "marketRent", title: "Market rent", editor: { type: "number" }, validators: [{ type: "required" }, { type: "number", min: 0, decimalPlaces: 2 }], }, { id: "rent", title: "Rent", editor: { type: "number" }, validators: [{ type: "number", min: 0, max: 999999.99, decimalPlaces: 2 }], },];The number editor strips a currency symbol and the file's own grouping as rows
enter the store, so $1,565.00 arrives as 1565.00 and 1,039 as 1039.
Neither needs a rule of your own. The 36 market rents written - are a
different case, since a dash is a value, so required passes and the number
rule reports them.
Beds, Baths and SqFt
Every header is scored against your schema, and anything under 60 out of 100 claims no column. Eleven of the fourteen clear that bar here with no configuration. The three that miss sit beside each other in the file.
| Header | Reaches | Why |
|---|---|---|
SqFt |
nothing | the camel hump splits it into sq and ft, against square and feet |
Beds |
nothing | beds sits nowhere inside bedrooms, and the lengths differ by four |
Baths |
nothing | the same, against bathrooms |
The edit-distance tier weighs lengths before it weighs characters. A string
of eight characters allows two edits. beds against bedrooms needs four, so
the matcher stops there and the header claims nothing. Three aliases settle all
three.
export const synonyms = { columns: { squareFeet: ["sqft", "sq ft", "net rentable area"], bedrooms: ["beds", "br"], bathrooms: ["baths", "ba"], }, values: { Occupied: ["occ", "mtm", "notice", "ntv"], Vacant: ["vac", "vacant-ready", "down", "model"], },};Occ, NTV and the words your schema knows
Value matching collects the status column across the whole upload. Eleven
distinct strings arrive and four of them land on their own. Occupied, Vacant
and Employee are exact. Vacant-Ready reaches Vacant because vacant sits
inside it.
| In the file | Rows | Reaches |
|---|---|---|
Occupied |
816 | Occupied, exact |
Occ |
365 | nothing on its own |
MTM |
181 | nothing on its own |
Notice |
159 | nothing on its own |
NTV |
118 | nothing on its own |
Vacant |
97 | Vacant, exact |
Employee |
46 | Employee, exact |
VAC |
43 | nothing on its own |
Vacant-Ready |
40 | Vacant, contains |
Down |
25 | nothing on its own |
Model |
15 | nothing on its own |
Occ, VAC, MTM and NTV all fail for one mechanical reason. The contains
tier ignores a string under four characters once case and punctuation come off,
so an abbreviation cannot reach the word it abbreviates.
Notice, Down and Model fail for a different reason. They name states your
enum does not carry, and where each one lands is a decision. A notice to vacate
is an occupied unit with a departure date. A month-to-month tenancy is occupied
with no end date. A unit down for repair holds nobody, and Fannie Mae writes the
model rule out in full. The values table above carries all four, so the
decision sits in your repository and the person is never asked to make it.
enableCustomValue: false closes the column, so a status nobody mapped is
dropped before it reaches your API.
Column by column, what arrives
| Column | What arrives | What your schema needs |
|---|---|---|
Prop |
six codes, and half of what identifies a row | required |
Property |
eleven names for six properties | one name for each code |
Property Address |
three properties spell their street two ways | no rule at all |
Unit |
540 labels over 1,710 units, A101 on seven rows |
required, and unique inside its property alone |
SqFt |
1,039, and 41 empty cells |
a whole number, with the comma taken off on the way in |
Status |
eleven strings | three options |
Resident |
empty on 214 rows, filled on 11 that hold nobody | a name wherever the status says somebody lives there |
Email |
30 addresses written name@example,com |
a valid address, or nothing |
Lease From |
43 written 07/2024 |
ISO, and those 43 stay flagged |
Lease To |
empty on 366 rows, 146 of them month to month | no rule, because an open lease is normal |
Market Rent |
- on 36 rows |
a number of zero and up |
Rent |
empty on every vacant row | a number, and empty stays empty |
The plain version of every failure in that table sits in common CSV import errors. What follows is the four that turn on the shape of a rent roll.
The property name that disagrees with itself
Greenfield Commons sits on 489 rows. Greenfield Commons Apartments sits on
40 and GREENFIELD COMMONS on 9, and all 538 are one building. A rule that
reads one row cannot see that, because a function validator is handed a value
and the row around it, and the answer lives in the other 537 rows.
{ type: "asyncFunction" } is the rule that reaches them. It receives every
cell in the column at once, and each cell carries the row it sits in.
import type { DataEditorColumn, ValidationError } from "@updog/data-editor";
export const propertyName: DataEditorColumn = { id: "propertyName", title: "Property name", size: 200, validators: [ { type: "required" }, { type: "asyncFunction", fn: async (cells) => { const tally = new Map<string, Map<string, number>>(); for (const cell of cells) { const code = String(cell.row.propertyCode ?? ""); const names = tally.get(code) ?? new Map<string, number>(); const name = String(cell.value ?? ""); names.set(name, (names.get(name) ?? 0) + 1); tally.set(code, names); }
const agreed = new Map<string, string>(); for (const [code, names] of tally) { const ranked = [...names].sort((a, b) => b[1] - a[1]); agreed.set(code, ranked[0][0]); }
return cells.map((cell): ValidationError | null => { const settled = agreed.get(String(cell.row.propertyCode ?? "")); return String(cell.value ?? "") === settled ? null : { level: "error", message: "The rest of this property is called " + settled }; }); }, }, ],};The sweep tallies names under each property code and keeps the one that appears most. Every cell holding anything else is flagged with the name the rest of the file agrees on. In this roll that is 81 rows, being 49 at Greenfield, 19 at Hawthorn, 9 at Lakeside and 4 at Riverbend. Two properties spell their name one way throughout, and the sweep leaves every row of them alone.
The address column disagrees on 91 rows and nothing flags it. Greenfield
writes 1400 Greenfield Rd on 485 rows and 1400 Greenfield Road on 53, and
Hawthorn and Stonegate do the same with St and Ln. A sweep covers the column
it was declared on, and the address column declares none.
Two leases on one unit
195 units in this roll carry more than one row, and three different things produce that.
Two roommates on one lease arrive as two rows with the same unit and the same dates. A unit that turned over arrives as two rows whose leases run end to end. And 15 units carry two leases whose dates overlap, which is the one case that is wrong.
Buildium publishes the rule for its own import. Rows sharing a property name, a unit number, a lease start and a lease end belong to one lease, and Buildium rejects any pair of leases on one unit whose dates overlap.
import type { DataEditorColumn, ValidationError } from "@updog/data-editor";
const openEnded = (value: unknown): string => { return String(value ?? "") || "9999-12-31";};
export const leaseFrom: DataEditorColumn = { id: "leaseFrom", title: "Lease from", editor: { type: "date" }, validators: [ { type: "date" }, { type: "asyncFunction", fn: async (cells) => { const byUnit = new Map<string, number[]>(); cells.forEach((cell, index) => { const unit = cell.row.propertyCode + "|" + cell.row.unitNumber; byUnit.set(unit, [...(byUnit.get(unit) ?? []), index]); });
const verdicts: (ValidationError | null)[] = cells.map(() => null); for (const group of byUnit.values()) { for (const a of group) { for (const b of group) { if (b <= a) continue; const oneFrom = String(cells[a].value ?? ""); const twoFrom = String(cells[b].value ?? ""); const oneTo = openEnded(cells[a].row.leaseTo); const twoTo = openEnded(cells[b].row.leaseTo); if (oneFrom === twoFrom && oneTo === twoTo) continue; if (oneFrom <= twoTo && twoFrom <= oneTo) { const error: ValidationError = { level: "error", message: "Two leases on this unit overlap", }; verdicts[a] = error; verdicts[b] = error; } } } } return verdicts; }, }, ],};The rule groups rows by property and unit. A pair carrying identical dates is one lease, so it skips those, and it flags both rows of every remaining pair whose intervals meet. An empty end date counts as open, so a month-to-month lease overlaps everything that starts after it. 28 rows carry that flag, being 14 units.
The lease that starts in a month with no day
43 lease starts in this roll are written 07/2024. Every numeric date pattern
the importer reads wants a day, so all 43 stay in the grid as the text the
export wrote, and the date rule reports every one of them.
That flag has a second effect. A cell that fails a sync rule never reaches the async sweep. The SDK holds back any cell that is empty or already carries an error, so the first pass over the lease column received 1,642 cells, being 1,905 less 220 empty and 43 left as text.
One of those 43 sits on unit B403 at Riverbend. Its lease runs to 10 July
2025, and the row beneath it runs from 6 September 2024 to 30 March 2025. Those
two overlap. The sweep was handed the second row alone, which has nothing to
compare against, so the file holds 15 overlapping units and the flag reaches 14.
The vacant unit with a name against it
11 rows say the unit is vacant and still carry the last resident. 5 rows say somebody lives there and name nobody. Both matter more here than they would in a flat schema, because the status decides how many records the row becomes.
A row landing on Vacant produces a unit and stops. A row on Occupied or Employee produces a unit, a lease and a person. So a vacant row carrying a name writes a resident with no lease behind them, and an occupied row with no name writes a lease nobody holds.
The rule is the function validator on residentName in the schema above, and
it reads the status beside it. dependentFields on the status column names the
resident column, so changing one rechecks the other.
Nothing here knows what a lease is
Updog ships no property template, no unit status list and no connector to any property management system. Updog Importer reads CSV, TSV, JSON, XML, XLSX, XLS, XLSB and ODS, and a rent roll that arrives as a PDF goes to a parser you supply, whose rows join the queue where a spreadsheet joins it. The fourteen columns, the three options, the alias table and both sweeps sit on top of that as code you keep.
The sweeps carry a limit worth knowing before you write one. Opening the file
calls each rule once with the whole column. Every call after that carries the
cells one edit touched. Correcting the 07/2024 start on unit B403 calls the
overlap rule with a single cell, and a single cell has nothing beside it, so
that pair carries no flag for the rest of the session. Each cell hands you its
row, and the row holds your fields and no identity of its own, so a rule cannot
keep an index between calls.
What a sweep paints is the file as it arrived.
The order the writes go in
<DataEditor columns={columns} synonyms={synonyms} primaryKey={["propertyCode", "unitNumber"]} onComplete={async (result) => { const rows = result.sources .flatMap((source) => source.rows) .filter((r) => r.isValid && !r.isDeleted) .map((r) => r.row);
const properties = await postProperties(rows); const units = await postUnits(properties, rows); await postLeases(units, rows.filter((r) => r.leaseStatus !== "Vacant")); }}/>onComplete hands back flat rows grouped by source, each carrying isNew,
isChanged, isDeleted and isValid. Turning them into four tables is a loop
you write, and the order inside it is the one Buildium publishes for its own
import. Properties go first, because a unit stores a property id. Units go next,
because a lease stores a unit id. Leases and residents go last.
primaryKey takes the pair, because a unit number means nothing without the
property it sits in. A101 is a real unit at all six properties in this roll.
Putting { type: "unique" } on the unit column instead would flag 1,770 of the
1,905 rows, since uniqueness is checked one column at a time and a unit number
is unique inside its property and nowhere else.
From one file to four tables
Out of 1,905 rows, 227 carry something a rule caught. 81 property names that
disagree with the rest of their property, 43 lease starts with no day, 36 market
rents written -, 30 addresses with a comma where a dot belongs, 28 rows in an
overlapping pair, 11 vacant units with a name against them and 5 occupied units
with none. Seven rows carry two of those at once.
The portfolio manager works down that list against the leases in their own filing cabinet, which is the only place those answers live. Then the loop turns 1,905 rows into 6 properties, 1,710 units, 1,573 leases and 1,680 residents, and every reference between them points at a record that exists.