File uploads in Go
You are building a profile page where users can upload an avatar. The frontend sends a POST request with Content-Type: multipart/form-data. Your Go handler receives the request, but the body is not a clean JSON object. It is a stream of binary chunks interleaved with headers and boundaries. You need to extract the file, validate it, save it to storage, and return a success response.
HTTP requests usually carry text. JSON, query strings, and URL-encoded forms are all text-based. File uploads break that pattern. The browser packages the file into a multipart message. Think of it like a shipping crate. The crate contains multiple items: the file data, the filename, the content type, and possibly text fields like a caption. The crate has boundaries between items so the receiver knows where one ends and the next begins. Go's net/http package handles the crate opening. You just need to tell it how much memory to use for the unpacking process.
Multipart parsing is streaming. Don't buffer what you can stream.
Minimal example
The core workflow involves three steps. Parse the form to unlock the multipart data. Extract the file field. Stream the file to a destination.
// HandleUpload processes a file upload from a multipart form.
func HandleUpload(w http.ResponseWriter, r *http.Request) {
// Limit memory usage to 32 MB. Larger files spill to disk automatically.
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "parsing failed", http.StatusBadRequest)
return
}
// Extract the file field named "file".
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
// Close the file handle when done to prevent resource leaks.
defer file.Close()
// Create the destination file.
dst, err := os.Create("upload.tmp")
if err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
defer dst.Close()
// Copy data from the upload stream to the destination.
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "copy failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Saved %s (%d bytes)", header.Filename, header.Size)
}
What happens under the hood
When ParseMultipartForm runs, Go reads the request body. It splits the stream at the boundaries defined in the Content-Type header. Small files and text fields stay in memory. If a file exceeds the memory limit you passed, Go writes the overflow to a temporary file on disk. This prevents a single upload from consuming all available RAM.
The FormFile method returns an io.Reader for the file data. You stream it to your destination using io.Copy. You never load the whole file into a byte slice. Streaming keeps memory usage flat regardless of file size. The FileHeader struct contains metadata. Filename is the name provided by the client. Size is the byte count. Header is a map of MIME headers. You can inspect header.Header.Get("Content-Type") to check the reported type, though MIME types are easily spoofed. Always validate the file content, not just the header.
After calling ParseMultipartForm, the parsed data lives in r.MultipartForm. You can access text fields via r.FormValue or r.PostFormValue. The ParseMultipartForm call populates both r.Form and r.MultipartForm. This means you can mix file uploads with regular form data in the same request.
Realistic example
Production code needs validation, security checks, and error handling. You should sanitize filenames to prevent directory traversal attacks. You should enforce size limits at the network level. You should wrap the request body to cut off oversized uploads early.
// UploadService handles file persistence with validation.
type UploadService struct {
storageDir string
maxBytes int64
}
// Save processes the upload, validates constraints, and writes to disk.
func (s *UploadService) Save(w http.ResponseWriter, r *http.Request) {
// Wrap the body to enforce a hard limit on total upload size.
r.Body = http.MaxBytesReader(nil, r.Body, s.maxBytes)
// Parse form with a 10 MB memory limit for buffering.
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("document")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
return
}
defer file.Close()
// Sanitize filename to prevent directory traversal attacks.
safeName := filepath.Base(header.Filename)
path := filepath.Join(s.storageDir, safeName)
dst, err := os.Create(path)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
defer dst.Close()
// Limit the copy to prevent writing more than expected.
if _, err := io.CopyN(dst, file, s.maxBytes); err != nil && err != io.EOF {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "uploaded")
}
The receiver name is usually one or two letters matching the type. (s *UploadService) is standard. Avoid (this *UploadService) or (self *UploadService). The http.MaxBytesReader wrapper protects against denial-of-service attacks. If the client sends more bytes than allowed, the connection is closed immediately. The ParseMultipartForm limit controls memory buffering. It does not stop a client from sending a large file. The server will start writing to disk. To enforce a hard limit, use MaxBytesReader.
The if err != nil pattern is verbose by design. It forces you to handle the unhappy path explicitly. Don't wrap it in a helper unless you lose the stack trace. Run gofmt on your code. The indentation and spacing are decided by the tool, not by personal preference.
Pitfalls and errors
File uploads introduce runtime risks that the compiler cannot catch. You must handle errors and resource cleanup manually.
If you forget to call ParseMultipartForm, FormFile returns an error. The runtime returns http: multipart form data not parsed. The compiler will not warn you. You must call ParseMultipartForm before accessing any multipart fields.
The ParseMultipartForm call creates temporary files for large uploads. These files are stored in the OS temp directory. They are deleted when the *http.Request is garbage collected. In long-running handlers or background goroutines, the request might stay alive longer than expected. Call r.MultipartForm.RemoveAll() explicitly when you are done to clean up disk space.
The worst upload bug is the one that fills your disk with temp files and never cleans them up.
If the client disconnects mid-upload, the read operation fails. The runtime returns http: multipart: next part: unexpected EOF. You should check for io.EOF or network errors and handle them gracefully.
Never trust header.Filename. It comes from the client. A malicious user can send ../../etc/passwd as the filename. Use filepath.Base to strip path components. The filepath.Base function returns the last element of the path. It removes directory separators and traversal sequences.
If you forget to import filepath, the compiler rejects the program with undefined: filepath. If you pass the wrong type to http.Error, you get cannot use err (variable of type error) as string value in argument. Always check your types.
In production code, your handler should accept a context.Context as the first argument. Pass it through to storage functions so you can cancel long uploads if the client disconnects. Context is plumbing. Run it through every long-lived call site.
Decision matrix
Choose the right tool based on your constraints.
Use ParseMultipartForm when you need to handle file uploads via HTML forms.
Use io.Copy when streaming file data to a destination without buffering the whole payload in memory.
Use filepath.Base when sanitizing client-provided filenames to prevent directory traversal.
Use io.CopyN when you need to enforce a strict byte limit during the write operation.
Use r.MultipartForm.RemoveAll when you want to clean up temporary files immediately after processing.
Use http.MaxBytesReader when you need to enforce a hard limit on the total request body size.
Use a cloud storage SDK when you need durable storage, CDN distribution, or offloaded bandwidth costs.
Local disk is for caching. Cloud storage is for persistence.