How to Upload a File with HTTP in Go (Multipart)

Web
Upload a file in Go using the mime/multipart package to create a multipart/form-data HTTP POST request.

The suitcase problem

You need to send a CSV report or a user avatar to an external API. The documentation says the endpoint expects multipart/form-data. You try to shove the file into a JSON payload, but the server rejects it. You try to manually construct the HTTP request body with string concatenation, but the boundary markers get misaligned and the upload fails silently.

Go gives you a clean way to pack files and form fields into a single HTTP request. The mime/multipart package handles the heavy lifting of generating boundaries, writing headers, and streaming payloads. You just need to understand the lifecycle of the writer and how it interacts with the HTTP client.

Multipart uploads are cheap to construct and predictable to send. Pack the data, set the header, and let the transport handle the rest.

How multipart actually works

The multipart format is defined in RFC 7578. Think of it as a structured suitcase. Each item inside gets its own compartment with a label. A unique divider string, called a boundary, separates the compartments. The boundary is a random string generated at runtime so it never accidentally appears inside your file data.

When you create a multipart request in Go, the multipart.Writer picks a random boundary and stores it. Every time you call CreateFormFile or CreateFormField, the writer prints the boundary, writes the Content-Disposition header, writes your payload, and prints another boundary. When you call Close(), it prints the final boundary with a trailing double dash to signal the end of the message.

The HTTP client does not automatically set the Content-Type header for you. You must call writer.FormDataContentType() and attach it to the request. This method returns multipart/form-data; boundary=----WebKitFormBoundary... with the exact random string the writer generated. If you hardcode the boundary or skip this step, the server cannot parse the request.

The writer streams directly into an io.Writer. Most tutorials use a bytes.Buffer because it keeps the whole payload in memory. That works fine for small files. Large files require a different strategy, which we will cover later.

Multipart is a streaming format. The writer pushes bytes forward. Never rewind.

The minimal in-memory upload

Here is the simplest way to upload a local file to an HTTP endpoint. The code opens a file, creates a multipart writer backed by a buffer, copies the file contents, closes the writer, and sends the request.

package main

import (
	"bytes"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)

func main() {
	// Open the local file for reading
	file, err := os.Open("report.csv")
	if err != nil {
		fmt.Println("failed to open file:", err)
		return
	}
	defer file.Close()

	// Buffer holds the complete request body in memory
	body := &bytes.Buffer{}
	// Writer generates boundaries and formats the multipart payload
	writer := multipart.NewWriter(body)

	// Create a file field named "upload" with the original filename
	part, err := writer.CreateFormFile("upload", "report.csv")
	if err != nil {
		fmt.Println("failed to create form file:", err)
		return
	}

	// Stream the file contents into the multipart part
	_, err = io.Copy(part, file)
	if err != nil {
		fmt.Println("failed to copy file:", err)
		return
	}

	// Finalize the multipart body and write the closing boundary
	err = writer.Close()
	if err != nil {
		fmt.Println("failed to close writer:", err)
		return
	}

	// Build the HTTP request with the buffered body
	req, err := http.NewRequest("POST", "https://example.com/upload", body)
	if err != nil {
		fmt.Println("failed to create request:", err)
		return
	}
	// Attach the exact Content-Type header with the generated boundary
	req.Header.Set("Content-Type", writer.FormDataContentType())

	// Send the request and print the response status
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	fmt.Println("status:", resp.Status)
}

The buffer accumulates every byte the writer produces. Once writer.Close() runs, the buffer contains a complete, valid multipart payload. The HTTP client reads the buffer and streams it over the network.

Keep the payload in memory only when it fits comfortably in RAM. Buffering is fast. Streaming is safer.

Step by step through the bytes

When multipart.NewWriter(body) runs, the package generates a random boundary string like ----GoMultipartBoundary7x9k2m. It stores this string internally. No bytes are written to the buffer yet.

Calling writer.CreateFormFile("upload", "report.csv") does three things. First, it writes the boundary to the buffer. Second, it writes the Content-Disposition: form-data; name="upload"; filename="report.csv" header. Third, it writes a Content-Type: application/octet-stream header. It returns an io.Writer that points to the same underlying buffer, positioned right after the headers.

When io.Copy(part, file) runs, it reads chunks from the file and writes them directly into the buffer. The multipart writer does not buffer the file contents separately. It streams them straight through. This keeps memory usage proportional to the buffer size, not the file size.

Calling writer.Close() writes the final boundary with a trailing double dash: ------GoMultipartBoundary7x9k2m--. This signals to the HTTP server that the multipart message is complete. If you skip this call, the server waits indefinitely for more data, and the request hangs.

The req.Header.Set("Content-Type", writer.FormDataContentType()) line is mandatory. The standard library does not guess the boundary for you. The method returns the exact string the writer used, so the client and server stay synchronized.

The writer owns the boundary. Trust it. Never override the Content-Type manually.

Real world: context, timeouts, and proper error handling

Production code needs cancellation support and network timeouts. The context.Context package is the standard way to thread cancellation through Go programs. By convention, context always goes as the first parameter to functions, and it is conventionally named ctx.

Here is how a production-ready upload function looks. It accepts a context, sets a timeout on the HTTP client, reads the response body, and checks for HTTP errors.

import (
	"context"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"time"
)

// UploadFile sends a local file to an HTTP endpoint using multipart/form-data.
func UploadFile(ctx context.Context, url, path string) error {
	// Open the file and ensure it closes when the function returns
	file, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open file: %w", err)
	}
	defer file.Close()

	// Buffer the request body in memory
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)

	// Create the form field and stream the file contents
	part, err := writer.CreateFormFile("file", path)
	if err != nil {
		return fmt.Errorf("create form file: %w", err)
	}
	_, err = io.Copy(part, file)
	if err != nil {
		return fmt.Errorf("copy file: %w", err)
	}

	// Finalize the multipart payload
	if err := writer.Close(); err != nil {
		return fmt.Errorf("close writer: %w", err)
	}

	// Build the request with the provided context for cancellation
	req, err := http.NewRequestWithContext(ctx, "POST", url, body)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", writer.FormDataContentType())

	// Client with a 30-second timeout prevents hanging on slow networks
	client := &http.Client{Timeout: 30 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()

	// Check for HTTP-level errors before reading the body
	if resp.StatusCode >= 400 {
		return fmt.Errorf("server returned %d", resp.StatusCode)
	}

	// Read and discard the response body to allow connection reuse
	_, err = io.Copy(io.Discard, resp.Body)
	if err != nil {
		return fmt.Errorf("read response: %w", err)
	}

	return nil
}

The function wraps every error with fmt.Errorf("prefix: %w", err). This preserves the original error chain while adding context. The community accepts this verbosity because it makes the failure path explicit. You never guess where an error came from.

The http.NewRequestWithContext call binds the request to the context. If the context cancels or times out, the HTTP client aborts the upload and returns an error. The io.Copy(io.Discard, resp.Body) call drains the response. If you skip this, the underlying TCP connection stays in a half-closed state and the HTTP client cannot reuse it for subsequent requests.

Context is plumbing. Run it through every long-lived call site.

When memory gets in the way

The bytes.Buffer approach works until you hit files larger than available RAM. Uploading a 2 GB video file into a buffer will panic with an out-of-memory error. The solution is to skip the buffer entirely and stream directly to the HTTP request.

The http.NewRequest function accepts an io.Reader as the body. You can pass the multipart writer's underlying buffer, or you can use a custom io.Pipe to stream the multipart payload directly to the network socket. Go's mime/multipart package does not expose a direct pipe writer, but you can wrap the writer around a io.PipeWriter.

Here is a streaming approach that keeps memory usage constant regardless of file size.

// UploadFileStream streams a file to an HTTP endpoint without buffering the whole payload.
func UploadFileStream(ctx context.Context, url, path string) error {
	// Create a pipe to stream multipart data directly to the network
	pr, pw := io.Pipe()
	writer := multipart.NewWriter(pw)

	// Spawn a goroutine to write the multipart payload asynchronously
	go func() {
		// Open the file inside the goroutine to avoid blocking the caller
		file, err := os.Open(path)
		if err != nil {
			pw.CloseWithError(fmt.Errorf("open file: %w", err))
			return
		}
		defer file.Close()

		// Create the form field and stream the file
		part, err := writer.CreateFormFile("file", path)
		if err != nil {
			pw.CloseWithError(fmt.Errorf("create form file: %w", err))
			return
		}
		_, err = io.Copy(part, file)
		if err != nil {
			pw.CloseWithError(fmt.Errorf("copy file: %w", err))
			return
		}

		// Close the writer and signal the pipe is done
		if err := writer.Close(); err != nil {
			pw.CloseWithError(fmt.Errorf("close writer: %w", err))
			return
		}
		pw.Close()
	}()

	// Build the request with the pipe reader as the body
	req, err := http.NewRequestWithContext(ctx, "POST", url, pr)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", writer.FormDataContentType())

	// Send the streaming request
	client := &http.Client{Timeout: 60 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return fmt.Errorf("server returned %d", resp.StatusCode)
	}

	_, err = io.Copy(io.Discard, resp.Body)
	return err
}

The io.Pipe connects a writer goroutine to a reader. The HTTP client reads from the pipe as the goroutine writes multipart bytes. Memory usage stays flat because only a small chunk of the file exists in RAM at any moment. The goroutine must call pw.CloseWithError on any failure so the HTTP client unblocks and returns an error instead of hanging.

Streaming adds a goroutine. Always give it a cancellation path or an error channel.

Common traps and compiler feedback

Forgetting to call writer.Close() is the most frequent mistake. The multipart payload never gets its closing boundary. The server waits for more data until the TCP timeout fires. The compiler will not catch this. You have to remember it.

If you try to use writer.FormDataContentType() before calling CreateFormFile or CreateFormField, the method still returns a valid boundary string. The payload will just be empty. The server rejects it with a 400 Bad Request.

If you forget to import mime/multipart, the compiler rejects the program with undefined: multipart. If you pass a string where an io.Reader is expected, you get cannot use "text" (untyped string constant) as io.Reader value in argument. Go's type system catches these mismatches early.

Another trap is ignoring the second return value from io.Copy. It returns the number of bytes copied and an error. If the file is truncated or the disk fails, io.Copy returns a short count and an error. Dropping the error with io.Copy(part, file) hides the failure. The community convention is to capture both values or use _, err := io.Copy(...). The underscore explicitly says you considered the byte count and chose to drop it.

Goroutine leaks happen when the streaming goroutine blocks on a channel or pipe that never gets read. Always attach the pipe reader to the HTTP request body, and always drain the response body. The worst goroutine bug is the one that never logs.

Choosing the right upload strategy

Use a bytes.Buffer with multipart.Writer when the file is under 50 MB and you want the simplest possible code. Use an io.Pipe with a background goroutine when the file exceeds available RAM or when you need to stream from a database or network source. Use CreateFormField alongside CreateFormFile when the API requires metadata like user IDs or timestamps in the same request. Use JSON encoding instead of multipart when the API accepts base64-encoded payloads and you want to avoid boundary management entirely. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next