Contents
- 1 Handling Large Csv Imports Without Running Out Of Memory
- 2 The Wrong Way: Loading Everything Into Memory
- 3 Core Idea: Stream Line By Line
- 4 Identify What’s Really Eating Your Memory
- 5 Batch Processing Instead Of Infinite Loops
- 6 Use The Database To Do Heavy Lifting
- 7 Generators: Clean Streaming Without Heavy Arrays
- 8 Keep The Inner Loop As Skinny As Possible
- 9 Sanity Checks Before You Even Start Importing
- 10 Config: When (And When Not) To Touch memory_limit
- 11 Background Workers: Don’t Block The User
- 12 Logging, Progress, And Not Losing Your Place
- 13 Practical Example: Million‑Row Import Without Fear
- 14 CSV Imports As A Reflection Of How We Work
Handling Large Csv Imports Without Running Out Of Memory
There’s a special kind of silence that happens when you hit “Import” on a 2‑gigabyte CSV file.
The browser hangs.
The spinner spins.
You glance at the server monitor and think: “Please don’t die, please don’t die.”
Friends, if you work with PHP long enough, you eventually meet The Big CSV.
It’s payroll exports, CRM dumps, shop orders from five years ago. It’s raw data that someone wants “in the system” by yesterday.
And then you watch your script eat memory like candy and crash at 128M.
Let’s talk honestly about that moment — and how to design imports that stay calm, predictable, and boringly reliable, even with millions of rows.
Because boring is what you want for data imports.
The Wrong Way: Loading Everything Into Memory
Let’s start with the mistake most of us have made:
$rows = file($pathToCsv); // or array_map, or something similar
foreach ($rows as $line) {
// parse, insert…
}
It works… until it doesn’t.
The failure pattern is always the same:
- everything looks fine in dev with a 2,000‑row sample
- QA tries 200,000 rows
- production adds a 2,000,000‑row export
- memory_limit explodes in a way that feels personal
Why? Because you just asked PHP to hold the entire file in memory. Even if each row is “just a bit of text,” it adds up. File buffers, arrays, parsed values, ORM objects — all quietly stack.
The truth is simple and slightly painful:
You almost never need the whole CSV in memory at once. Don’t load what you don’t need.
Core Idea: Stream Line By Line
The mindset shift is this:
Instead of “load file, then process,” think “read one line, process one line, forget it, move on.”
With PHP, streaming a CSV is almost boringly straightforward:
$handle = fopen($pathToCsv, 'r');
if ($handle === false) {
throw new RuntimeException('Cannot open file');
}
while (($row = fgetcsv($handle)) !== false) {
// $row is an array of columns for one line
// validate, transform, save…
}
fclose($handle);
Key things happening here:
- memory stays flat: you only hold one row (plus whatever you build from it)
- file size stops mattering: 10 MB or 2 GB, peak memory can be similar if your processing is lean
- you don’t fear “huge CSV” emails from managers anymore
If you use this approach and still run out of memory, the culprit is usually not fgetcsv() — it’s what you’re doing inside that loop.
Identify What’s Really Eating Your Memory
Picture a late evening: two monitors, one black coffee gone cold, and an import script that keeps dying after exactly 150,000 rows.
You bump memory_limit. Still dies.
You swear quietly. Then you start looking deeper.
Typical memory leaks in CSV imports:
- building huge arrays of all rows “to process later”
- creating thousands of ORM entities and keeping them referenced
- collecting “errors” and “stats” for every single row in memory
- storing raw CSV content and transformed data side by side
The habit we need to unlearn: “I’ll process everything, then save.”
The habit we need to learn: “I’ll process, save, and forget in small chunks.”
Batch Processing Instead Of Infinite Loops
Streaming line‑by‑line is step one.
Step two is batching — handle rows in groups, then let PHP breathe.
Imagine this for database inserts:
$batchSize = 1000;
$buffer = [];
$handle = fopen($pathToCsv, 'r');
$header = fgetcsv($handle); // optional: skip header
while (($row = fgetcsv($handle)) !== false) {
$buffer[] = $row;
if (count($buffer) === $batchSize) {
insertBatch($buffer); // bulk insert or loop
$buffer = []; // clear, free memory
}
}
// tail batch
if (!empty($buffer)) {
insertBatch($buffer);
}
fclose($handle);
Why this helps:
- you do fewer database calls
- your memory usage for
$bufferis bounded: never more thanbatchSizerows - you have a natural checkpoint if something fails (“we imported up to batch N”)
On platforms like Find PHP, where people care about fast, dependable imports for real businesses, this kind of design is the difference between “it mostly works” and “we can trust this in production.”
Use The Database To Do Heavy Lifting
There’s a moment in every developer’s life when you realize you’re trying to do in PHP what the database was literally built to do.
For huge imports, consider:
LOAD DATA INFILEin MySQL orCOPYin Postgres to bulk‑load into a temp table- let the database handle:
- type conversions
- basic validation
- discarding bad rows
- simple transformations
Workflow can look like this:
- stream rows from CSV
- write them to a temp file or pipe them directly to the DB bulk import
- once in a temp table, run SQL to:
- join with lookup tables
- normalize into multiple tables
- update existing records
This shifts memory pressure away from PHP. Your PHP process stays lean; the database does what it’s good at — handling lots of rows efficiently.
On high‑traffic job platforms or internal admin panels, this kind of architecture keeps imports from competing with user‑facing requests for precious PHP memory.
Generators: Clean Streaming Without Heavy Arrays
If you’ve ever written a function that returns “all rows as an array,” you know how quickly that scales badly.
Replace it with a generator:
function csvRows(string $path): \Generator
{
$handle = fopen($path, 'r');
if ($handle === false) {
throw new RuntimeException('Cannot open file');
}
while (($row = fgetcsv($handle)) !== false) {
yield $row;
}
fclose($handle);
}
And then:
foreach (csvRows($pathToCsv) as $row) {
// process one row at a time
}
What this gives you:
- simple, readable code
- streaming behavior by default
- no giant arrays of data lying around
In frameworks like Laravel, you get this idea in the form of LazyCollections; in plain PHP, generators are your lightweight friend.
Keep The Inner Loop As Skinny As Possible
There’s a quiet rule with massive CSVs:
The inner loop decides whether your import finishes or dies.
The “inner loop” is everything that happens between while (($row = fgetcsv())) and the end of that iteration.
Try to keep it:
- light on I/O: don’t hit external APIs per row if you can batch or prefetch
- light on allocations: no unnecessary arrays or objects
- predictable: avoid complex dynamic structures that grow over time
Instead of:
while ($row = fgetcsv($handle)) {
$customerData = callExternalApi($row);
// heavy logic…
// write three different files…
}
Think more like:
- prefetch external data before the import, or pull it in batches
- write to disk or DB in batches instead of per row
- do heavy transformations in a second pass, after initial import, if necessary
When you’re importing millions of rows, even small operations inside that loop become large over time. This is where slow leaks live.
Sanity Checks Before You Even Start Importing
A lot of pain can be avoided before you start reading the file.
Good habits:
- set sensible limits
- max file size
- max allowed rows (if your use case doesn’t need “infinite”)
- validate the header row
- check required columns exist
- check naming, order, encoding
- reject garbage early
- wrong delimiter
- broken encoding
- completely unexpected structure
A simple approach:
- open file
- read just the first line (header)
- verify expected columns
- optionally read 10–20 sample rows and validate them
- only then start the full import
You save yourself from streaming half a million rows just to realize at the end that the “email” column was actually “comments” and nothing maps.
Config: When (And When Not) To Touch memory_limit
Let’s be fair: sometimes you do need to adjust PHP settings a bit.
Tuning that’s usually reasonable:
max_execution_time: long imports often need more than the defaultmemory_limit: if your script is truly lean and still close to the limit, a modest bump can be OK- upload size limits: to allow the CSV to arrive in the first place
But there’s a principle I learned the hard way:
Increase memory_limit after you’ve profiled and fixed your code, not before.
If your script holds one row and a small batch at a time, you shouldn’t need massive memory.
If you do, something else is wrong — often a slowly growing array or a forgotten reference.
On platforms that let people upload arbitrary CSVs for years, you want code that stays stable even as data grows, instead of repeatedly raising resource limits until you hit the wall again.
Background Workers: Don’t Block The User
There’s a reality we sometimes ignore: importing a few million rows can take minutes.
Making a user stare at the browser that entire time is… not friendly.
Better pattern:
- user uploads file and configures mapping
- you:
- store the file
- create an “import job” record
- dispatch a background worker (queue, cron, CLI script) to process
Then:
- the worker streams and processes rows using the strategies above
- you update progress: “132,000 of 850,000 rows imported”
- the user is free to close the tab, keep working, come back later
Technically, this can be:
- a long‑running CLI import script started by a queue
- a supervisor‑managed worker
- a cron‑triggered job for periodic imports
Emotionally, it changes everything:
The import is no longer a tense wait. It’s a job that runs quietly in the background while people do their actual work.
Logging, Progress, And Not Losing Your Place
One of the worst feelings as a developer: an import crashes at 70% and you don’t know exactly what made it fail — or where to resume.
For large CSVs, add:
- row counters
- keep track of how many rows you’ve processed
- log milestones (“processed 100k rows…”)
- incremental logs
- write errors to a log file or a DB table as you go
- include row number, raw data, validation failures
- resumable workflows
- store “last successful row index”
- on restart, skip rows until that index
On something like a recruitment or job‑matching platform, this matters a lot. Those CSVs often represent candidates, jobs, or application histories. Losing half an import because of a silent crash is more than a technical issue — it’s real data loss for real people.
Practical Example: Million‑Row Import Without Fear
Let’s build a mental scenario.
You have:
- a CSV with ~1,000,000 product records
- about 20 columns each
- need to import into MySQL and:
- create or update products
- sync categories
- attach vendors
A sane plan:
- step 1: analyze
- which columns do we actually need?
- what’s the max file size we’ll allow?
- step 2: pre‑import checks
- open the file
- read header and 20 sample rows
- validate formats, required fields
- step 3: import design
- CLI worker with
set_time_limit(0) - streaming rows with
fgetcsv() - batch size of, say, 2,000 rows
- CLI worker with
- step 4: DB strategy
- bulk insert into a temp table (or staging table)
- use SQL to:
- upsert into main product table
- link categories and vendors via indexed keys
- step 5: logging
- write import progress to a table: job_id, processed_rows, errors_count
- log failed rows separately with reasons
Result:
- memory stays stable because you keep only a small buffer and one row at a time
- database does the heavy relational work
- if the import fails at 570,000 rows, you know exactly where, why, and what to fix
This is the difference between feeling nervous every time someone uploads a CSV and feeling quietly confident that it will just work.
CSV Imports As A Reflection Of How We Work
I find something oddly human in how we design CSV imports.
You can see the team’s mindset in the code:
- do we rush and “just load everything” because the deadline was yesterday?
- do we take time to think about memory, streaming, and failures?
- do we respect the fact that behind every row there might be a person, an order, a salary?
Large CSV imports are not glamorous. There’s no fancy UI, no animation, no “wow” factor.
But they sit at the heart of many systems — HR platforms, CRMs, job boards, finance tools.
Building them with care — streaming instead of hoarding, batching instead of brute forcing, logging instead of hoping — is a quiet form of professionalism.
The kind that doesn’t shout, doesn’t show up in marketing copy, but makes life easier for everyone who depends on our code.
And maybe that’s the right note to end on:
Some of the best PHP we write isn’t the clever stuff, it’s the calm, steady code that takes a huge messy CSV and turns it into something reliable, one line at a time.