Data Migration — Bulk CSV Import
Data Migration — Bulk CSV Import
The data migration module lets organisation owners and admins bulk-import landlord and property records from CSV files. It is designed for one-time or periodic migrations from legacy systems.
Access: Owner and admin roles only. This feature is not available to standard members.
Accessing the Module
Navigate to Dashboard → Admin → Data Migration (/dashboard/admin/data-migration).
The page shows:
- Summary cards — total migrated landlords, total migrated properties, and overall job count
- Import wizard — create a new import job in three steps
- Import history — all past jobs with status, row counts, and expandable error details
Import Wizard
The wizard guides you through three steps:
Step 1 — Select Type
Choose whether you are importing Landlords or Properties.
Step 2 — Upload CSV
- Enter an Import Label to identify this job (e.g.
Legacy Landlords from Xero - Jan 2025). - Review the Expected CSV Columns panel — required columns are highlighted in red.
- Upload your
.csvfile using the file picker or drag-and-drop. - If you need a reference file, click Download Sample CSV to get a pre-formatted example.
- Click Validate to submit the file for parsing and validation.
Step 3 — Review & Import
After validation you will see:
- Total rows, valid rows, and error rows found in the file
- Detected columns from your CSV header row
- Validation errors (up to 20 shown) with row numbers and field names
If there are errors you can go back, fix your CSV, and re-upload. If the file is acceptable, click Run Import to execute.
CSV Format
Landlords
| Column | Required | Notes |
|---|---|---|
first_name | ✅ | |
last_name | ✅ | |
email | ✅ | Must be a valid email address |
phone | ||
landlord_type | Enum value (e.g. individual, company) | |
address_line_1 | ||
city | ||
postcode |
Sample:
first_name,last_name,email,phone,landlord_type,address_line_1,city,postcode
John,Smith,john.smith@example.com,07700900000,individual,10 Downing Street,London,SW1A 2AA
Jane,Doe,jane.doe@example.com,07700900001,company,1 Business Park,Manchester,M1 1AA
Properties
| Column | Required | Notes |
|---|---|---|
address_line_1 | ✅ | |
city | ✅ | |
postcode | ✅ | |
property_type | Enum value (e.g. single_let, hmo) | |
bedrooms | Numeric | |
bathrooms | Numeric | |
landlord_email | Links property to an existing landlord |
Sample:
address_line_1,city,postcode,property_type,bedrooms,bathrooms,landlord_email
10 High Street,London,E1 6AN,single_let,2,1,john.smith@example.com
5 Park Road,Manchester,M1 4BH,hmo,6,3,jane.doe@example.com
CSV formatting notes:
- Fields containing commas must be wrapped in double quotes
- BOM characters (common in Excel exports) are handled automatically
- Files must use UTF-8 encoding
Duplicate Handling
- Landlords: Rows with an email address already associated with a landlord in the same organisation are silently skipped — they are not counted as errors.
- Properties: No duplicate detection by default; each row creates a new property record.
Error Handling
- Up to 500 validation or import errors are stored per job.
- The first 20 errors are surfaced in the wizard review step and in the job detail view.
- Each error includes the row number, field name, and a human-readable message.
- Import-time failures are captured for debugging.
Job Statuses
| Status | Meaning |
|---|---|
pending | Job created, not yet processed |
validating | CSV is being parsed |
validated | Validation complete, awaiting execution |
importing | Import in progress |
completed | All processable rows imported |
failed | Job encountered a fatal error |
Audit Trail
Every successful migration leaves a verifiable audit trail:
- Imported landlord and property records are flagged with
isMigrated = true. - Each record stores the
migrationJobIdit was created by. - Audit log entries are written for the
data_migration.validatedanddata_migration.completedevents.
tRPC API Reference
All endpoints are under the dataMigration router and require admin or owner permissions.
dataMigration.getColumnSpec
Returns the required and optional column names for a given import type.
Input:
{ importType: "landlords" | "properties" }
Returns:
{ requiredColumns: string[], optionalColumns: string[] }
dataMigration.validate
Parses and validates a raw CSV string. Creates a job record in validated status.
Input:
{
importType: "landlords" | "properties",
label: string,
csvData: string
}
Returns:
{
jobId: string,
status: string,
totalRows: number,
validRows: number,
errorRows: number,
hasErrors: boolean,
detectedColumns: string[],
sampleErrors: Array<{ rowNumber: number, field: string | null, message: string }>
}
dataMigration.execute
Imports validated rows from a job into the database.
Input:
{ jobId: string }
dataMigration.list
Lists migration jobs for the organisation.
Input:
{ limit?: number, importType?: "landlords" | "properties" }
dataMigration.get
Returns a single job and all its associated errors.
Input:
{ jobId: string }
dataMigration.summary
Returns aggregate migration statistics.
Returns:
{
totalMigratedLandlords: number,
totalMigratedProperties: number,
jobStats: Record<string, number> // status → count
}