Exporting a Million Rows Without Running Out of Memory

I remember sitting in a darkened server room five years ago, watching a progress bar freeze at 98% while the database connection timed out and my stomach dropped. Every tutorial I had ever read on how to export large datasets assumed you were working with a pristine, low-latency environment and a dataset that actually fits in memory. They never mention the part where your connection drops halfway through a ten-million-row migration, leaving you with a corrupted CSV and a production database that is currently gasping for air because your query locked every single table.

I’m not here to give you a sanitized, step-by-step guide that only works when everything goes right. Instead, I want to talk about what happens when things go wrong. I’m going to show you how to handle the memory overflows, the timeout errors, and the inevitable connection resets that occur when you move real-world data. We’re going to skip the “happy path” and focus on building an export process that is actually resilient enough to survive the messiness of a live system.

Table of Contents

Preventing Out of Memory Errors Before They Kill Your Process

Preventing Out of Memory Errors Before They Kill Your Process

The mistake most people make is treating a million-row export like a simple `SELECT *` query. In a tutorial, that works fine. In production, that’s how you trigger an OOM killer that takes your entire service down with it. If you try to pull the entire result set into your application’s memory at once, you aren’t just exporting data; you’re building a time bomb.

The solution isn’t more RAM; it’s chunked data reading. Instead of letting the database dump everything into your heap, you need to pull it in manageable slices. I usually lean on database pagination techniques—specifically keyset pagination—to move through the records. Using `OFFSET` is a trap; as you get deeper into the table, the database has to scan through every single previous row just to find where you left off, which kills your performance.

By implementing batch processing large files, you keep your memory footprint flat regardless of whether you’re exporting ten thousand rows or ten million. You want your process to look like a steady, predictable stream, not a massive spike that ends in a crash.

The Disaster of Unoptimized Etl Pipelines in Production

The Disaster of Unoptimized Etl Pipelines in Production

I’ve seen it happen more times than I care to count: a developer writes a clean, logical script that works perfectly on their local machine with a thousand rows of test data. Then, they push it to production, the scheduled job triggers, and the entire pipeline chokes on ten million rows. The problem isn’t usually the logic; it’s the assumption that resources are infinite. When you try to pull an entire table into memory at once, you aren’t just running a query—you’re essentially setting a trap for your own infrastructure.

The real nightmare starts when you realize your optimizing ETL pipelines strategy was actually just a “hope for the best” strategy. Without implementing proper chunked data reading, your service will attempt to swallow the entire dataset in a single, massive gulp. You’ll watch the CPU spike, the memory usage climb vertically, and then—silence. The process dies, leaving behind nothing but a half-finished file and a frantic Slack notification from the on-call engineer. It’s a predictable, painful cycle that usually stems from treating a production database like a local CSV file.

The Five Ways Your Export Process Will Actually Fail

  • Stop trying to load the whole result set into an array. If you’re using a standard `SELECT *` and waiting for the driver to return a massive object, you aren’t exporting data; you’re just building a memory bomb that will take down your container. Use a cursor or a streaming interface to pull rows one by one.
  • Your database isn’t a file system. If you try to write a single 50GB CSV file directly to a local disk, you’re going to hit I/O bottlenecks or run out of space mid-stream. Stream the output directly to an object store like S3 or write to multiple smaller, chunked files instead.
  • Forget about “real-time” requirements for massive exports. If you try to run a heavy export against your primary production replica during peak hours, you’ll spike the CPU and lock rows, causing a cascade of timeouts for actual users. Schedule these for the dead hours, or better yet, run them against a dedicated analytical snapshot.
  • Check your character encoding before you start the clock. I’ve seen multi-hour exports fail at the 99% mark because a single row contained an unexpected UTF-8 character that the destination format couldn’t handle. Validate your data types and encoding expectations before the first byte is even read.
  • Implement a way to resume. If your network blips or a pod restarts during a massive transfer, you don’t want to start from row zero. Build your export logic with checkpoints or offset-based pagination so you can pick up where the failure happened instead of wasting another four hours.

The Reality Check

Stop trying to load the whole dataset into a single variable; if you aren’t using cursors or streaming, you aren’t writing a process, you’re writing a crash report.

A “successful” export isn’t one that finishes, it’s one that doesn’t starve your other production services of CPU or memory while it’s running.

Always write your rollback logic before you touch the export script, because the moment the network blips halfway through a 10GB write, you’re going to need a way to clean up the mess.

The Illusion of the Success Metric

A tutorial will show you a green checkmark next to a completed export, but in a production environment, a successful export that locks your database tables for twenty minutes is just a slow-motion system failure.

Pernille Askeland

Beyond the Successful Run

Beyond the Successful Run: managing resource constraints.

At the end of the day, exporting a large dataset isn’t just about writing a single, clever SQL query; it’s about managing the unseen friction between your code and the hardware. We’ve talked about why you can’t just dump everything into a single memory buffer, why unoptimized ETL pipelines turn into production nightmares, and how to stop your process from being killed by the OOM killer. If you’ve done it right, you aren’t just moving data; you are respecting the limits of your infrastructure. You’ve moved from the “happy path” of a tutorial to the reality of resource constraints, ensuring that your export is a predictable background task rather than a system-wide emergency.

I’ve spent twenty years watching systems buckle under the weight of data they weren’t prepared to carry. It’s easy to feel like a failure when a migration hangs or a database locks up, but those moments are actually where the real engineering happens. Don’t be discouraged when the first attempt fails; that’s just the system telling you where the boundaries are. Build your processes with the expectation of failure in mind, and you’ll find that you aren’t just writing code—you’re building something actually resilient. Now, go check your logs, fix that buffer, and get that data moving.

Frequently Asked Questions

How do I handle a situation where the database connection times out halfway through a ten-hour export?

You don’t “handle” a ten-hour timeout; you design for it. If your export is a single, monolithic transaction, you’ve already lost. You need to implement checkpointing. Break the dataset into logical, offset-based chunks—think by ID ranges or timestamp windows. Store your progress in a state table. When the connection drops at hour six, your script shouldn’t panic; it should just query the state table, find the last successful offset, and resume.

Is it actually safer to stream the data to a file, or am I just trading memory errors for disk I/O bottlenecks?

It’s a trade-off, plain and simple. You aren’t escaping the bottleneck; you’re just moving it from the RAM to the disk. If you buffer everything in memory, you get a hard crash and a very expensive morning of post-mortems. If you stream, you get a slow, grinding process that keeps the system alive. I’ll take a slow export that finishes at 3:00 AM over a crashed server every single time.

What happens to the production database's performance when I start pulling millions of rows for a CSV export during peak hours?

You’re not just pulling data; you’re holding a massive, long-lived lock on the database’s attention. As your query crawls through millions of rows, the buffer pool gets flooded with export data, pushing out the hot pages your actual users need for daily transactions. Suddenly, simple lookups turn into slow disk reads. If you’re using a heavy transaction isolation level, you might even stall writes entirely. It’s a slow-motion collision between a report and your revenue.

About Pernille Askeland

I write the part the tutorial skipped: what it looks like when it breaks.

Scroll to Top