How to Store Uploaded Files Without Filling Your Database

How to Store Uploaded Files Without Filling Your Database

For most applications the right answer is to store the file bytes on the filesystem or in an object store, and keep only a pointer to them in the database. The database should hold the metadata, meaning the path or key, the original filename, the MIME type, the size, a checksum, the owner, and the timestamp. It should not hold the bytes themselves. The database vs filesystem question is really a question about which system is good at what, and once you see the two jobs separately, the decision usually makes itself.

The reason is mechanical, not stylistic. A relational database stores rows in fixed size pages, and it keeps those pages in a buffer pool in memory. When you put a large binary value in a row, you either push the row past the page size and force the engine to store the value out of line, or you bloat the table so that every scan touches far more pages than it needs to. Reads that should hit the buffer pool start going to disk. Backups grow because the dump now contains every uploaded image. Replication traffic grows for the same reason. None of that happens if the row is a few hundred bytes of text and the bytes live somewhere else.

The protocol decides where the bytes go

When a browser submits a form with a file input and the form uses multipart/form-data, the request body is a sequence of parts separated by a boundary string. The Content-Type header carries that boundary, and each part has its own headers, including Content-Disposition with a filename parameter. Your framework parses this for you, but it helps to see the raw shape:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----x9f2
Content-Length: 24113

------x9f2
Content-Disposition: form-data; name="avatar"; filename="cat.png"
Content-Type: image/png

<binary bytes>
------x9f2--

Most application servers spool that body to a temporary file once it crosses a threshold, then hand your code a path or a stream. That threshold is a config directive in nearly every runtime, and it is worth knowing. If you raise it, you hold more of the upload in memory, which is fine for small files and dangerous for large ones. If you lower it, you get more disk churn in the temp directory but a smaller resident set. Either way, the parser has already made a decision about where the bytes live before your handler runs.

From there you have two moves. You can move or copy the temp file into a directory your application serves, or you can stream it to an object store with an HTTP PUT. The first is a rename on the same filesystem, which is cheap. The second is a network round trip, which is not, but it buys you durability you did not have to build.

Storing uploads on the filesystem: what you inherit

If you keep files on local disk, you own the directory layout, the permissions, and the cleanup. A common shape is a sharded path derived from a hash of the key, so no single directory holds a million entries:

uploads/
  a3/
    f9/
      a3f9c1e8b7d4....bin

The hash also gives you a natural deduplication check. Before writing, compute a digest of the incoming bytes and look for an existing record with the same digest. If it matches, you skip the write and point the new row at the old file. This only works if you store the digest in the database, which is another reason the database should hold metadata rather than blobs.

The costs you inherit are real. Local disk is tied to one machine, so a second application server cannot see the first server's uploads unless you mount shared storage. Shared storage adds a single point of failure and a latency floor. Backups now have two halves, the database dump and the file tree, and they must be taken in a consistent order or a restored database will point at files that were never copied. The usual fix is to write the file first, then commit the row, and to run a periodic job that finds rows whose file is missing and files whose row is missing.

Serving is the other half. If you let the application read the file and echo it back, every download occupies a worker process. A web server can serve static paths directly and skip your code entirely, which is faster and cheaper. If the files are private, you sign a short lived URL or check authorization in a small handler, but you still want the actual byte transfer to happen outside your application process.

Storing uploads in the database: when it is defensible

There are cases where the database is the better home. If you need the file and its row to be updated in the same transaction, keeping them together removes a whole class of inconsistency. If your dataset is small, if the files are small, and if you already run a database with a strong backup story, the simplicity can outweigh the bloat. Some engines have a dedicated type for this, and using it is better than a generic blob column because the engine can store the value out of line and leave the main table compact.

The tradeoffs do not disappear. You still pay for the bytes in every dump, in every replica, and in every restore. You still cannot serve the file without going through your application, unless your database exposes an HTTP interface, and even then you are putting a database in the request path for static content. Query plans get worse as the table grows. The moment you want a CDN in front of the files, you are back to copying them out.

Migrating after you chose wrong

Migration is mostly bookkeeping. Add the new columns first, backfill them, then flip reads and writes. For a move from blob columns to disk, the sequence looks like this. Add a nullable storage_key column. Walk the table in batches and write each blob to its new path, updating the key as you go. Verify by comparing the stored digest against a fresh digest of the file on disk. Then switch the read path to prefer storage_key and fall back to the blob column. Only after a full pass, and after you are confident nothing is still reading the old column, drop it.

Do it in batches, and make each batch idempotent so a crash mid run does not corrupt anything. Keep the old column until the new path has been live long enough that a rollback is no longer plausible. The same shape works in reverse if you move to an object store, except the write step is an HTTP PUT and the verification step is a HEAD that checks the reported length and, if the store supports it, the checksum you sent.

Start by writing down where your bytes currently live and who reads them. Then add the digest column if you do not have one, because it is the only cheap way to prove a copy is correct. Then pick the storage that matches your access pattern and your backup window, and make the database row a pointer rather than a container.

Related articles

Subscribe to our newsletter

Get the latest hosting tips, performance insights, and industry news.