Seed a Database From CSV: Laravel, Django, Rails (2026)
How to seed a database from a CSV file in Laravel, Django and Rails, when to skip the ORM for COPY or LOAD DATA, and how to keep seeds re-runnable.
Seed a Database From CSV: Laravel, Django, Rails
Someone hands you a spreadsheet of country codes, subscription plans or tax rates and it needs to be in every developer's database, in CI, and in production. The file is a CSV. Your framework's seeder is not. This is the practical guide to seeding a database from a CSV in Laravel, Django and Rails — plus the raw COPY and LOAD DATA paths, and the four data problems that break seeds quietly.
TL;DR
- Convert the CSV to JSON once and commit the JSON, not the CSV.
- Laravel: a class in
database/seeders, usingupsert()in chunks. - Django:
loaddatacannot read CSV — write a management command. - Rails:
db/seeds.rbmust be idempotent;upsert_allbeatscreate!. - Six figures of rows or more: skip the ORM, use
COPYorLOAD DATA.
Why a CSV makes a good seed source and a bad seed format
The CSV is usually where the data starts, because it is the only format the person who owns the data can edit. That is a genuine advantage. It is also the reason a CSV should not be what your seeder actually reads.
CSV is the format your non-engineers can edit
Reference data — plans, regions, VAT rates, feature flags, product categories — tends to be owned by finance, ops or legal, not engineering. Those teams edit spreadsheets. Any workflow that requires them to hand-edit JSON will decay within two quarters. So accept the CSV as the input and move the conversion into your own process.
The three things a CSV will never tell you
A CSV carries characters between delimiters and nothing else. Everything a database needs beyond that has to come from somewhere else:
| Missing | Consequence |
|---|---|
| Types | 01234 becomes 1234 |
| Nulls | Empty string and NULL are identical |
| Relations | Foreign keys are just strings |
This is not a defect in any particular parser. The format simply has no vocabulary for it, which is why RFC 4180 describes fields as text data and stops there.
Convert once, commit the result
The pattern that survives contact with a real team: take the CSV, convert it to JSON in one deliberate step where you decide the types, commit that JSON to the repository alongside the seeder, and let the seeder read JSON. You get a reviewable diff on every data change, no CSV-parsing dependency in your seeder, and no chance of a stray semicolon delimiter silently reshaping the file in someone's European Excel.
Run the conversion in the CSV ↔ JSON Converter — it stays in your browser, which matters when the file has customer or pricing data in it. If the source is a .xlsx rather than a CSV, convert that directly instead of exporting to CSV first; you skip a whole round of type guessing.
How to seed a database from a CSV in Laravel
Laravel seeders live in database/seeders and expose a single run() method, invoked by php artisan db:seed. Per the Laravel seeding documentation, mass-assignment protection is disabled automatically during seeding, so you can insert wide rows without fighting $fillable.
Reading the converted JSON inside a seeder
// database/seeders/PlanSeeder.php
public function run(): void
{
$path = database_path('data/plans.json');
$rows = json_decode(file_get_contents($path), true);
foreach (array_chunk($rows, 500) as $chunk) {
DB::table('plans')->upsert(
$chunk,
['code'],
['name', 'price_cents']
);
}
}
Register it from DatabaseSeeder with $this->call([PlanSeeder::class]) so ordering is explicit, and run a single seeder in isolation with php artisan db:seed --class=PlanSeeder.
Why you should chunk your inserts
upsert() builds one SQL statement per call. Hand it 20,000 rows and you get a statement with 20,000 value tuples, which will hit the placeholder limit on MySQL and the parameter limit on Postgres long before it finishes. Chunks of 500–1000 rows are a safe default and turn the whole seed into a handful of statements rather than 20,000 round trips.
Making the seeder safe to re-run
insert() will throw on the second run. upsert() takes the columns that identify a row and the columns to overwrite on conflict, which makes the seeder idempotent — the property you actually want, because seeds get re-run after every migrate:fresh --seed and in every CI job. If your seed touches models with observers you do not want firing, add the WithoutModelEvents trait to the seeder class.
How to import CSV data into Django
Django's answer to seed data is the fixture, and this is where people lose an afternoon: fixtures are not CSV.
Why loaddata cannot read a CSV file
The Django fixtures documentation defines a fixture as the serialized contents of the database, and the file extension must match a registered serializer — JSON, JSONL, XML, or YAML with PyYAML installed. A fixture record carries three keys: model, pk and fields. A CSV row has nowhere to put the first two. So loaddata plans.csv is not a supported call, and no amount of renaming will make it one.
You can convert your CSV into a real fixture — wrap each row in the pk/model/fields envelope — and that is the right move for small, static reference tables you also want available to tests via fixtures = ["plans"]. Note that fixture loading writes rows as-is: custom save() methods are skipped and signal handlers receive raw=True.
Writing a management command instead
For anything with real volume, a management command is cleaner than a hand-built fixture:
# plans/management/commands/load_plans.py
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from plans.models import Plan
class Command(BaseCommand):
def handle(self, *args, **options):
path = Path("plans/data/plans.json")
rows = json.loads(path.read_text(encoding="utf-8"))
Plan.objects.bulk_create(
[Plan(**row) for row in rows],
update_conflicts=True,
update_fields=["name", "price_cents"],
unique_fields=["code"],
batch_size=500,
)
bulk_create, update_conflicts and batch_size
bulk_create does not call save() and does not send pre_save/post_save, which is the trade you make for speed. update_conflicts=True with unique_fields gives you the upsert behaviour that makes re-runs safe; support depends on the backend, so check it against your database rather than assuming. batch_size splits the operation into multiple statements for the same reason chunking matters in Laravel.
How to seed a Rails database from a CSV file
Rails has the smallest surface area of the three: one file, db/seeds.rb, run by bin/rails db:seed.
The idempotency rule is not optional
The Rails migrations guide is explicit that seed code should be idempotent so it can be executed at any point in every environment. That is a stronger requirement than it looks, because db:prepare will load seeds on a fresh database and skip them on an existing one — so your seed file will be executed an unpredictable number of times across the machines in your team. Write it so a second run is a no-op.
# db/seeds.rb
require "json"
path = Rails.root.join("db/data/plans.json")
rows = JSON.parse(path.read, symbolize_names: true)
Plan.upsert_all(rows, unique_by: :code)
find_or_create_by! vs upsert_all
find_or_create_by! is the documented idiomatic form and is the right choice for a dozen rows: it runs validations and callbacks, and reads clearly. upsert_all issues a single statement, skips validations and callbacks entirely, and is what you want past a few thousand rows. Pick per table, not per project.
Reloading seeds without dropping the database
bin/rails db:seed:replant truncates the tables and re-runs the seed file, which is the command you want when the JSON changed but the schema did not. db:reset drops and rebuilds everything, which is heavier and loses any local data you cared about.
When to skip the ORM and use COPY or LOAD DATA
Every approach above walks rows through your application. Below roughly six figures that cost is irrelevant. Above it, the database's own bulk loader is an order of magnitude faster.
PostgreSQL: COPY, \copy, and the ON_ERROR clause
\copy plans (code, name, price_cents)
FROM 'plans.csv'
WITH (FORMAT csv, HEADER MATCH);
Two details worth knowing. COPY reads a file from the server's filesystem and is restricted to superusers or holders of pg_read_server_files; psql's \copy runs COPY FROM STDIN and reads from the client's filesystem, which is almost always what you actually want. And per the PostgreSQL COPY documentation, HEADER MATCH verifies that the header names and order match the table columns instead of blindly discarding the first line — a cheap guard against a reordered export. Recent versions also add ON_ERROR ignore with REJECT_LIMIT, which loads the good rows and reports the discarded count rather than failing the whole file.
MySQL: LOAD DATA and the LOCAL flag
LOAD DATA LOCAL INFILE 'plans.csv'
INTO TABLE plans
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(code, name, price_cents);
IGNORE 1 LINES is what skips the header row. The LOCAL keyword is the one that will cost you time: MySQL's LOAD DATA reference notes that local_infile is disabled by default and must be enabled on both server and client, and that non-LOCAL reads are constrained by secure_file_priv. If you see "The used command is not allowed with this MySQL version", that is this setting, not a version problem.
What you give up
| Approach | Throughput | App validations |
|---|---|---|
| ORM row-by-row | Low | Yes |
| Bulk upsert | High | No |
| COPY / LOAD DATA | Highest | No |
Only the first row runs your model validations. The other two assume the file is already correct — which is the real argument for converting and inspecting the data before it reaches the database, rather than after.
The four data problems that break CSV seeds
These account for most of the "the seed ran but the data is wrong" reports.
- Leading zeros.
01234read as a number is1234, permanently. Force the column to text during conversion and store it asvarchar. - Empty string vs NULL. CSV cannot distinguish them. Postgres
COPYtreats an unquoted empty field as NULL and""as an empty string; decide which you want per column and be explicit. - Encoding. A file exported from Excel without a UTF-8 BOM will round-trip non-ASCII names into mojibake. Fix the encoding before the seed, not after.
- Ordering. Foreign keys mean seed order matters. Laravel's
$this->call([...])array, Django's explicit command sequence, and a top-to-bottomdb/seeds.rball give you deterministic ordering — use it rather than relying on discovery order.
Validate the JSON before you commit it
The converted JSON is the artifact that actually ships, so read it before it lands. A quick pass through the JSON Decoder catches the two failure modes that are invisible in a diff: a numeric field that should have stayed a string, and a null that arrived as the four characters n-u-l-l.
When the source is a SQL dump, not a CSV
If what you were handed is a .sql file full of INSERT statements rather than a spreadsheet, going straight to a seeder is the wrong shape. Convert it to a table first with the SQL Converter, review the columns, and then take the same CSV-to-JSON-to-seeder path. It is one extra step and it turns an opaque blob into something you can diff.
References
- Database: Seeding — Laravel 12.x — seeder structure,
db:seed --class,WithoutModelEvents, and the production--forceprompt. - Fixtures — Django documentation — registered serializer formats, fixture discovery order, and the
raw=Truesignal behaviour. - Active Record Migrations — Ruby on Rails Guides —
db/seeds.rbidempotency guidance and thedb:prepare/db:seed:replantbehaviour. - PostgreSQL: Documentation: COPY —
HEADER MATCH,ON_ERROR,REJECT_LIMIT, and the server-file privilege rules behind\copy. - MySQL LOAD DATA Statement —
IGNORE n LINES,local_infiledefaults, andsecure_file_privconstraints. - RFC 4180 — Common Format and MIME Type for CSV Files — confirmation that the format defines no types, nulls or relations.
Related on iKit
- Start with the end-to-end CSV to JSON conversion — the conversion step this whole workflow depends on, covered properly.
- Decide which format the data should live in first — why the seed artifact you commit should be JSON, not the original CSV.
- Stop the parser turning your IDs into numbers — the leading-zero and big-ID failures that survive all the way into your database.
- Nested data has to be flattened before it fits a CSV grid — the reverse trip, for when you export seed data back out for review.
- The quoting and newline cases that break CSV readers — worth reading before you trust any hand-edited seed file.
- Fix garbled characters before they reach the database — the UTF-8 BOM problem behind mojibake in seeded reference tables.
- Turn a SQL dump into something reviewable — when the handover is a
.sqlfile rather than a spreadsheet.
Related posts
Lorem Ipsum Markdown: Filler for READMEs and Docs (2026)
Lorem Ipsum Markdown that survives the renderer: which blocks to fill, the characters that quietly become syntax, and how filler behaves on import to Notion.
Lorem Ipsum HTML: Generate Markup-Ready Filler Fast (2026)
Lorem Ipsum HTML in seconds: Emmet abbreviations, browser generators, the right tag for each block, and the lang trap that makes screen readers stumble.
Web Audio API Beep: Build a Browser Alarm Sound (2026)
A Web Audio API beep needs no audio file. One oscillator, one gain node and five lines of JavaScript give any timer an alarm that never fails to load.