How to Handle File Uploads in a Go HTTP Server

Web
Handle file uploads in Go by parsing multipart forms with ParseMultipartForm and saving the file stream using FormFile.

The empty file mystery

You add an input to your form. You submit a PDF. The server logs a 400 error. Or worse, the handler runs, but the file is empty. You check r.FormValue("file") and get an empty string. The data is in the request. Go just isn't looking in the right place.

HTML forms default to sending data as URL-encoded text. That format works for strings. It chokes on binary files. The browser switches to a multipart format when you add enctype="multipart/form-data" to the form tag. Go's standard library doesn't parse multipart bodies automatically. You have to opt in. The fix is calling ParseMultipartForm before you try to read any fields.

How multipart parsing works

Multipart data looks like a series of blocks separated by a boundary string. Each block has headers and a body. The boundary is a random string generated by the browser. Go needs to know this boundary to split the stream.

ParseMultipartForm reads the Content-Type header, extracts the boundary, and scans the body. It builds a multipart.Form struct. This struct holds the file parts and any text fields. The function also enforces a memory limit. If a file part exceeds the limit, the parser writes the excess to a temporary file. This prevents a single upload from consuming all your server's RAM. The limit is a cap on memory usage, not a cap on file size. Files larger than the limit go to disk automatically.

The parser returns an error if the body is malformed or the limit is exceeded. If the request isn't multipart, the function returns an error immediately. You must check the error. The compiler won't catch a missing call to ParseMultipartForm. If you skip it, FormFile returns a runtime error like http: multipart form parsing failed.

Parse the form. Stream the data. Never trust the filename.

Minimal upload handler

Here's the simplest working handler. It parses the form, extracts the file, and streams it to disk.

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	// 32 MB memory cap; excess data spills to a temp file on disk automatically
	err := r.ParseMultipartForm(32 << 20)
	if err != nil {
		http.Error(w, "parse error", http.StatusBadRequest)
		return
	}

	// FormFile returns the file reader and metadata like filename
	file, _, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "missing file", http.StatusBadRequest)
		return
	}
	defer file.Close() // Always close the reader to release resources

	// io.Copy reads from the request and writes to disk in efficient chunks
	dst, err := os.Create("upload.bin")
	if err != nil {
		http.Error(w, "create error", http.StatusInternalServerError)
		return
	}
	defer dst.Close()

	_, err = io.Copy(dst, file)
	if err != nil {
		http.Error(w, "copy error", http.StatusInternalServerError)
		return
	}

	fmt.Fprintln(w, "uploaded")
}

func main() {
	http.HandleFunc("/upload", uploadHandler)
	http.ListenAndServe(":8080", nil)
}

Walking through the flow

When the request arrives, the body is a raw stream of bytes. ParseMultipartForm reads the stream until it hits the boundary markers. It splits the stream into parts. Each part gets stored in r.MultipartForm. The function populates r.MultipartForm.File for file fields and r.MultipartForm.Value for text fields.

FormFile is a convenience wrapper. It looks up the field name in r.MultipartForm and returns the reader. You get an io.Reader. You don't get the whole file in memory. You stream it. io.Copy pulls chunks from the request reader and pushes them to the destination writer. This pattern works for 1 MB files and 1 GB files with the same memory footprint.

The defer file.Close() call is essential. The multipart.File holds a file descriptor. If you don't close it, the descriptor leaks. The OS has a limit on open files. Eventually, os.Create fails with too many open files. The community convention is to check errors immediately with if err != nil. It's verbose by design. The boilerplate makes the unhappy path visible. You can't accidentally swallow an error.

io.Copy is the standard way to move data. Use it.

Real-world validation and security

Production code needs more than os.Create. You need to check the file size. You need to sanitize the filename. You might want to limit the MIME type. The browser sends the filename in the header. That string is user input. It can contain path separators or null bytes. Never use the filename directly in os.Create.

Here's a handler that validates size and sanitizes the name.

func safeUploadHandler(w http.ResponseWriter, r *http.Request) {
	// 10 MB memory limit; adjust based on your service requirements
	err := r.ParseMultipartForm(10 << 20)
	if err != nil {
		http.Error(w, "parse failed", http.StatusBadRequest)
		return
	}

	file, header, err := r.FormFile("document")
	if err != nil {
		http.Error(w, "file required", http.StatusBadRequest)
		return
	}
	defer file.Close()

	// Check size from header to reject oversized files early
	if header.Size > 5<<20 {
		http.Error(w, "file too large", http.StatusBadRequest)
		return
	}

	// Sanitize filename to prevent directory traversal attacks
	name := sanitizeFilename(header.Filename)
	path := fmt.Sprintf("uploads/%s", name)

	dst, err := os.Create(path)
	if err != nil {
		http.Error(w, "server error", http.StatusInternalServerError)
		return
	}
	defer dst.Close()

	// io.CopyN limits the write to the declared size
	_, err = io.CopyN(dst, file, header.Size)
	if err != nil {
		http.Error(w, "write error", http.StatusInternalServerError)
		return
	}

	fmt.Fprintln(w, "saved")
}

The sanitizeFilename function should strip dangerous characters. A simple approach is to use filepath.Base to remove directory components, then replace spaces and special characters. Better yet, generate a random UUID for the stored filename and keep the original name in a database. This avoids collisions and path traversal entirely.

io.CopyN stops after writing header.Size bytes. If the client sends less data than declared, io.CopyN returns io.ErrUnexpectedEOF. That catches truncated uploads. The client can lie about the size in the header. io.CopyN protects you from writing more than expected.

Sanitize filenames. Limit sizes. Close files.

Request body limits

ParseMultipartForm has a memory limit. It doesn't limit the total upload size. A client can send a 10 GB file. The parser writes it to a temp file. Your disk fills up. You need http.MaxBytesReader to cap the total request body.

Wrap the request body with MaxBytesReader before parsing. This limits the total bytes read from the client. If the client exceeds the limit, the server closes the connection.

func limitedUploadHandler(w http.ResponseWriter, r *http.Request) {
	// Cap total request body at 50 MB to protect disk space
	r.Body = http.MaxBytesReader(w, r.Body, 50<<20)

	err := r.ParseMultipartForm(10 << 20)
	if err != nil {
		http.Error(w, "upload limit exceeded", http.StatusRequestEntityTooLarge)
		return
	}

	// ... rest of handler
}

MaxBytesReader returns an error when the limit is hit. The error message includes http: request body too large. You can check for this specific error or just return a 413 status. The memory limit in ParseMultipartForm and the body limit in MaxBytesReader serve different purposes. The memory limit controls RAM usage during parsing. The body limit controls total bandwidth and disk usage. Use both.

Trust the limits. Cap the body. Cap the memory.

Common pitfalls and errors

The most common mistake is passing a small integer to ParseMultipartForm. The argument is the memory limit in bytes. Passing 32 sets the limit to 32 bytes. The parser immediately spills to disk or errors out. Use bit shifts like 32 << 20 for megabytes. The compiler won't warn you about the value. It's a runtime behavior.

Another trap is calling ParseForm instead of ParseMultipartForm. ParseForm parses URL-encoded data. It ignores multipart bodies. If you call ParseForm on a multipart request, you get nothing. The compiler rejects the program with undefined: ParseForm if you forget the import, but if you import net/http, the method exists. The error is logical, not syntactic.

If you forget to capture the loop variable when processing multiple files, the compiler rejects the program with loop variable i captured by func literal in Go 1.22+. This applies if you're iterating over files in a goroutine. Always capture the variable or use the loop parameter feature.

Goroutine leaks aren't the issue here, but resource leaks are. If you open a file and panic before closing it, the file descriptor leaks. defer handles that. The worst goroutine bug is the one that never logs. If you spawn a goroutine to process the upload and it fails silently, you lose the file. Return errors from goroutines via channels or use errgroup.

The compiler complains with cannot use x as int64 if you pass a float to ParseMultipartForm. The argument must be an integer. Use int64 literals or casts.

Sanitize input. Check errors. Close resources.

When to use what

Use r.FormFile when you need a simple upload handler that streams directly to disk or another service. Use r.MultipartReader when you need fine-grained control over the parsing process, such as validating headers before reading the body. Use r.ParseMultipartForm combined with r.FormValue when the request contains both files and standard form fields like text inputs. Use http.MaxBytesReader when you need to cap the total request body size to protect disk space and bandwidth. Use io.Copy when moving data between readers and writers; it handles buffering and chunking efficiently. Use io.LimitReader when you want to cap the amount of data read from the request to prevent abuse. Use a third-party library like chi or gorilla/mux when you need middleware to handle uploads across multiple endpoints, though the standard library remains sufficient for most cases.

Goroutines are cheap. Channels are not magic.

Where to go next