The form submits, the server crashes
You build a profile page. Users upload avatars. You add the input element, set the type to file, and submit the form. The server returns a 400 error. The logs show multipart: NextPart: EOF. Or worse, the server accepts the file, saves it using the client-provided name, and now you have a file named ../../etc/passwd overwriting your system config.
File uploads look simple on the client side. On the server, they involve parsing binary boundaries, managing memory limits, sanitizing paths, and handling streams. Gin wraps the standard library to make this manageable, but you still need to control the flow. The browser sends data differently for files than for text. You have to match that format or the request fails.
Multipart forms and boundaries
HTTP requests usually send data as URL-encoded text. A form with a name and age becomes name=alice&age=16. This works for strings. Files are binary. You cannot stuff a JPEG into a URL parameter without encoding it, and base64 encoding inflates the size by 33 percent.
Browsers switch to multipart/form-data when a form contains a file input. This format splits the request into parts separated by a boundary string. Think of the boundary like a divider in a moving box. The browser packs the form fields and the file into separate compartments, and the boundary string marks where one compartment ends and the next begins. The server reads the request, finds the boundary, extracts the headers, and gives you the bytes.
The HTML form must declare this encoding. If you forget enctype="multipart/form-data", the browser sends URL-encoded data. The server looks for a boundary, finds none, and fails.
<!-- The enctype attribute tells the browser to use multipart encoding. -->
<!-- Without this, file uploads fail with a parsing error on the server. -->
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" />
<button type="submit">Upload</button>
</form>
Go's mime/multipart package handles the parsing. Gin exposes this through the Context. You don't write the parser. You ask Gin for the file, and it returns the metadata and a reader.
The boundary string is the contract. If the client lies, the server rejects.
Minimal example: grabbing a file
Here's the smallest handler that grabs a file and checks for errors. It reads the first few bytes to prove the stream works.
func uploadHandler(c *gin.Context) {
// FormFile triggers parsing and returns the first file for the key "avatar".
// It returns a FileHeader with metadata and a File interface.
file, header, err := c.FormFile("avatar")
if err != nil {
// Return 400 if the file is missing or the form is malformed.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// header.Filename is the name from the client.
// Never use this directly for storage paths.
fmt.Println("Client filename:", header.Filename)
// Open returns an io.Reader to stream the file content.
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open file"})
return
}
// Ensure the file is closed to release resources.
defer src.Close()
// Read the first 16 bytes to inspect the content type or magic number.
buf := make([]byte, 16)
n, _ := src.Read(buf)
fmt.Printf("First %d bytes: %v\n", n, buf)
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
How the parsing works
When the request arrives, Gin does not parse the body automatically. You have to ask for it. Calling c.FormFile triggers the parsing. It reads the request body, finds the boundary, and locates the part named "avatar".
The function returns two values. The first is a *multipart.File. This implements io.Reader. You use it to stream the data. The second is a *multipart.FileHeader. This holds metadata like Filename, Size, and Header. The header also has an Open method. This design separates metadata from the stream. You can inspect the size and name before you commit to reading the content.
Memory management happens behind the scenes. Go reads the multipart form into memory up to a limit. If the data exceeds the limit, Go writes the excess to a temporary file on disk. The default limit is 32 megabytes. You can change this with c.MaxMultipartForm. If the file is larger than the limit, FormFile still works. The Open method reads from the temp file. You don't need to handle the split manually. The temporary files live in os.TempDir and are cleaned up when the request ends, but relying on automatic cleanup can leave debris if the process crashes. Explicit limits keep your disk safe.
The convention here follows "accept interfaces, return structs." FormFile returns a concrete FileHeader struct you can inspect. The Open method returns an io.Reader interface. This lets you pass the stream to any function that accepts a reader, like io.Copy or an image processing library.
Memory limits protect your server. Set them early.
Realistic example: saving with validation
Real applications need to save the file, check the size, and generate a safe name. Gin provides SaveUploadedFile to copy the content to disk. This function handles the stream copy and closes the file.
func saveUpload(c *gin.Context) {
// Set the max memory for parsing the multipart form.
// Data larger than this gets written to a temp file on disk.
c.MaxMultipartForm = 8 << 20 // 8 MB
file, header, err := c.FormFile("document")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate file size. header.Size is in bytes.
const maxFileSize = 10 << 20 // 10 MB
if header.Size > maxFileSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large"})
return
}
// Generate a unique name to avoid collisions and path traversal attacks.
// Never trust header.Filename for the destination path.
destName := fmt.Sprintf("%d_%s", time.Now().UnixNano(), header.Filename)
destPath := filepath.Join("uploads", destName)
// SaveUploadedFile copies the content from the multipart reader to the file.
// It handles opening, copying, and closing the source.
if err := c.SaveUploadedFile(file, destPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save file"})
return
}
c.JSON(http.StatusOK, gin.H{"path": destPath})
}
The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Every error check is a decision point. Here we return JSON, but the pattern holds: check the error, handle it, return.
Sanitize the filename. Trust the boundary, not the client.
Context and cancellation
File uploads can take time. A user might upload a large video and close the tab halfway through. The server should stop processing when the client disconnects. The request context tracks this. c.Request.Context() returns the context associated with the request. If the client cancels, the context is cancelled.
For small files, SaveUploadedFile finishes quickly. For large files, you might want to check the context during the copy. io.Copy does not check context automatically. You can wrap the destination writer or use a library that supports context. Gin's SaveUploadedFile does not check context. If you need cancellation support for large uploads, write the copy loop yourself and check c.Request.Context().Done().
context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. Here we use the context from the Gin context directly.
func uploadWithContext(c *gin.Context) {
// Parse the form and get the file reader.
file, _, err := c.FormFile("video")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not open"})
return
}
defer src.Close()
// Create the destination file.
dest, err := os.Create("uploads/video.mp4")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create"})
return
}
defer dest.Close()
// Buffer reduces syscalls during the copy loop.
buf := make([]byte, 32*1024)
// ... loop follows ...
}
The loop checks the context before every read. This pattern ensures the server stops work immediately when the client drops.
// Inside the handler, after setting up src and dest.
for {
// Check context before every read to respect client cancellation.
select {
case <-c.Request.Context().Done():
// Client disconnected. Clean up partial file and abort.
dest.Close()
os.Remove("uploads/video.mp4")
c.JSON(http.StatusRequestTimeout, gin.H{"error": "upload cancelled"})
return
default:
// Context is still active. Proceed with read.
}
n, readErr := src.Read(buf)
if n > 0 {
_, writeErr := dest.Write(buf[:n])
if writeErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
return
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
return
}
}
The underscore _ discards a value intentionally. In the loop, we discard the write count because we only care about the error. Use _ sparingly with errors. Here we check writeErr explicitly.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
If the client sends a broken request, the parser fails. You get errors like multipart: NextPart: header too large if the boundary is missing or the headers are malformed. The compiler won't catch runtime parsing errors. You must check the error from FormFile.
Common runtime errors include multipart: NextPart: EOF if the request body ended unexpectedly. This happens if the client sends a truncated body or if the form encoding is wrong. multipart: NextPart: header too large indicates headers exceed the limit. This can signal a malformed request or an attack. http: multipart: next part: unexpected EOF is similar to EOF, often caused by network drops during upload.
Path traversal is a security risk. The header.Filename comes from the client. A malicious user can send ../../etc/passwd. If you use this string in filepath.Join, you might escape the upload directory. Always generate a unique name on the server. Use a UUID or timestamp. Strip the original filename or use only the extension.
Memory leaks can occur if you don't close the file. defer src.Close() prevents this. SaveUploadedFile handles closing, but if you call Open manually, you must close the reader. Temporary files created by the parser are cleaned up when the request ends, but explicit cleanup is safer.
Leaks happen when you forget to close. defer is your friend.
Testing uploads
Testing file uploads requires creating a multipart request. The mime/multipart package provides a Writer to build the body. You create a buffer, write the form parts, and set the content type.
func TestUploadHandler(t *testing.T) {
// Build the multipart body in memory.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add a file part. CreateFormFile returns a writer for the part content.
part, err := writer.CreateFormFile("avatar", "test.png")
if err != nil {
t.Fatal(err)
}
part.Write([]byte("fake image data"))
// Close finalizes the boundary string and writes the closing delimiter.
writer.Close()
}
The FormDataContentType method returns the correct header value including the boundary. Without this, the server cannot parse the request.
// Continue in the test function.
req := httptest.NewRequest(http.MethodPost, "/upload", body)
// Set the content type including the boundary string.
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
engine := gin.New()
engine.POST("/upload", uploadHandler)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
Testing this way ensures your handler works with real multipart data. The boundary string in the content type must match the one used to build the body.
Tests catch the boundary errors. Real traffic catches the rest.
Decision matrix
Use c.FormFile when you need to access a single file field from a multipart form. Use c.SaveUploadedFile when you want to copy the uploaded content to a local file path without writing the copy loop yourself. Use c.Request.ParseMultipartForm when you need to access multiple files or form fields manually via the standard library. Use c.MaxMultipartForm when you want to control how much data stays in memory before spilling to disk. Use a cloud storage SDK when you need to upload directly to S3 or GCS instead of saving to the local server disk. Use io.Copy with a custom destination when you need to process the stream, like resizing an image or hashing the content before saving.