---
title: "Upload an Image With Go"
description: "Build a typed multipart image upload with Go net/http, decode JSON, close response bodies, and verify the public URL."
slug: "upload-image-go"
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: "Create a multipart file part with its real image media type, send it with net/http, decode success or error JSON, and verify the result."
cluster: Languages
intent: how-to
sources:
  - title: "Go mime/multipart package documentation"
    url: "https://pkg.go.dev/mime/multipart"
  - title: "Go net/http package documentation"
    url: "https://pkg.go.dev/net/http"
  - title: "Go encoding/json package documentation"
    url: "https://pkg.go.dev/encoding/json"
  - title: "imgd.dev OpenAPI specification"
    url: "https://imgd.dev/openapi.json"
---

Upload an image with Go by building a `multipart/form-data` body with the field name `file`. Set the part media type to the real image type. Send the request with `net/http`, accept HTTP `200` or `202`, decode the JSON, and close every response body.

## Before you upload

imgd.dev costs **$1 per GB as a one-time storage purchase**. It has **no free tier**. Every imgd.dev image is public to anyone with its URL. Do not upload a private, confidential, or secret image.

Use Go 1.22 or later. This example uses only the standard library.

Load `IMGD_KEY` from a secret manager. Confirm that it exists without printing it:

```bash
test -n "$IMGD_KEY"
```

## Complete Go example

Save this module file as `examples/core/go/go.mod`:

```go
module example.com/imgd/core/go-upload

go 1.22
```

Save this code as `examples/core/go/main.go`:

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/textproto"
	"os"
	"path/filepath"
	"strings"
	"time"
)

const (
	uploadURL       = "https://imgd.dev/v1/upload"
	metadataURL     = "https://imgd.dev/v1/images"
	maxUploadBytes  = 20 * 1024 * 1024
	requestTimeout  = 60 * time.Second
	maxResponseBody = 1024 * 1024
)

var mimeTypes = map[string]string{
	".jpg":  "image/jpeg",
	".jpeg": "image/jpeg",
	".png":  "image/png",
	".gif":  "image/gif",
	".webp": "image/webp",
	".avif": "image/avif",
}

var statusGuidance = map[int]string{
	http.StatusUnauthorized:          "Load a valid IMGD_KEY from the secret manager.",
	http.StatusPaymentRequired:       "Buy storage or delete unused images, then retry.",
	http.StatusRequestEntityTooLarge: "Reduce the image below 20 MB, then retry.",
	http.StatusUnsupportedMediaType:  "Use JPEG, PNG, GIF, WebP, or AVIF with the correct media type.",
}

type uploadResponse struct {
	Hash         string  `json:"hash"`
	URL          string  `json:"url"`
	Status       string  `json:"status"`
	MIME         string  `json:"mime"`
	Bytes        int64   `json:"bytes"`
	Width        *int    `json:"width"`
	Height       *int    `json:"height"`
	AltText      *string `json:"alt_text"`
	UnpublishAt  *string `json:"unpublish_at"`
	Deduplicated bool    `json:"deduplicated,omitempty"`
	Note         string  `json:"note"`
}

type apiError struct {
	Error string `json:"error"`
	Fix   string `json:"fix"`
}

type metadataResponse struct {
	Hash string `json:"hash"`
}

func buildMultipartBody(imagePath string) (*bytes.Buffer, string, error) {
	info, err := os.Stat(imagePath)
	if err != nil {
		return nil, "", fmt.Errorf("inspect image: %w", err)
	}
	if info.Size() == 0 {
		return nil, "", fmt.Errorf("the image file is empty")
	}
	if info.Size() > maxUploadBytes {
		return nil, "", fmt.Errorf("the image is larger than the 20 MB upload limit")
	}

	extension := strings.ToLower(filepath.Ext(imagePath))
	mediaType, ok := mimeTypes[extension]
	if !ok {
		return nil, "", fmt.Errorf("use a JPEG, PNG, GIF, WebP, or AVIF file name")
	}

	file, err := os.Open(imagePath)
	if err != nil {
		return nil, "", fmt.Errorf("open image: %w", err)
	}
	defer file.Close()

	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	header := make(textproto.MIMEHeader)
	header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{
		"name":     "file",
		"filename": filepath.Base(imagePath),
	}))
	header.Set("Content-Type", mediaType)

	part, err := writer.CreatePart(header)
	if err != nil {
		return nil, "", fmt.Errorf("create multipart file field: %w", err)
	}
	if _, err := io.Copy(part, file); err != nil {
		return nil, "", fmt.Errorf("copy image into multipart body: %w", err)
	}
	if err := writer.Close(); err != nil {
		return nil, "", fmt.Errorf("close multipart body: %w", err)
	}

	return body, writer.FormDataContentType(), nil
}

func responseError(response *http.Response) error {
	var payload apiError
	if err := json.NewDecoder(io.LimitReader(response.Body, maxResponseBody)).Decode(&payload); err != nil {
		return fmt.Errorf("request failed with HTTP %d and invalid JSON", response.StatusCode)
	}
	if payload.Error == "" {
		payload.Error = "unknown_error"
	}
	if payload.Fix == "" {
		payload.Fix = statusGuidance[response.StatusCode]
	}
	if payload.Fix == "" {
		payload.Fix = "Read the response and correct the request."
	}
	return fmt.Errorf("request failed with HTTP %d (%s): %s", response.StatusCode, payload.Error, payload.Fix)
}

func uploadImage(client *http.Client, imagePath, apiKey string) (uploadResponse, error) {
	body, contentType, err := buildMultipartBody(imagePath)
	if err != nil {
		return uploadResponse{}, err
	}

	request, err := http.NewRequest(http.MethodPost, uploadURL, body)
	if err != nil {
		return uploadResponse{}, fmt.Errorf("create upload request: %w", err)
	}
	request.Header.Set("Authorization", "Bearer "+apiKey)
	request.Header.Set("Content-Type", contentType)

	response, err := client.Do(request)
	if err != nil {
		return uploadResponse{}, fmt.Errorf("send upload request: %w", err)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusAccepted {
		return uploadResponse{}, responseError(response)
	}

	var upload uploadResponse
	if err := json.NewDecoder(io.LimitReader(response.Body, maxResponseBody)).Decode(&upload); err != nil {
		return uploadResponse{}, fmt.Errorf("decode upload response: %w", err)
	}
	if upload.Hash == "" || upload.URL == "" || upload.Status == "" || upload.MIME == "" {
		return uploadResponse{}, fmt.Errorf("the upload response does not match the expected success contract")
	}

	return upload, nil
}

func verifyUpload(client *http.Client, upload uploadResponse, apiKey string) error {
	request, err := http.NewRequest(http.MethodGet, metadataURL+"/"+upload.Hash, nil)
	if err != nil {
		return fmt.Errorf("create metadata request: %w", err)
	}
	request.Header.Set("Authorization", "Bearer "+apiKey)

	metadataHTTPResponse, err := client.Do(request)
	if err != nil {
		return fmt.Errorf("send metadata request: %w", err)
	}
	defer metadataHTTPResponse.Body.Close()

	if metadataHTTPResponse.StatusCode != http.StatusOK {
		return responseError(metadataHTTPResponse)
	}
	var metadata metadataResponse
	if err := json.NewDecoder(io.LimitReader(metadataHTTPResponse.Body, maxResponseBody)).Decode(&metadata); err != nil {
		return fmt.Errorf("decode metadata response: %w", err)
	}
	if metadata.Hash != upload.Hash {
		return fmt.Errorf("the metadata response did not confirm the upload hash")
	}

	publicHTTPResponse, err := client.Get(upload.URL)
	if err != nil {
		return fmt.Errorf("request public URL: %w", err)
	}
	defer publicHTTPResponse.Body.Close()

	if publicHTTPResponse.StatusCode != http.StatusOK {
		return fmt.Errorf("the public URL returned HTTP %d", publicHTTPResponse.StatusCode)
	}
	contentType := publicHTTPResponse.Header.Get("Content-Type")
	if !strings.HasPrefix(contentType, "image/") {
		return fmt.Errorf("the public URL returned the unexpected type %q", contentType)
	}
	firstByte := make([]byte, 1)
	if _, err := io.ReadFull(publicHTTPResponse.Body, firstByte); err != nil {
		return fmt.Errorf("read public image body: %w", err)
	}

	return nil
}

func run() error {
	if len(os.Args) != 2 {
		return fmt.Errorf("usage: go run . IMAGE_PATH")
	}
	apiKey := os.Getenv("IMGD_KEY")
	if apiKey == "" {
		return fmt.Errorf("set IMGD_KEY in the environment")
	}

	client := &http.Client{Timeout: requestTimeout}
	upload, err := uploadImage(client, os.Args[1], apiKey)
	if err != nil {
		return err
	}
	if err := verifyUpload(client, upload, apiKey); err != nil {
		return err
	}

	encoder := json.NewEncoder(os.Stdout)
	encoder.SetIndent("", "  ")
	if err := encoder.Encode(upload); err != nil {
		return fmt.Errorf("write upload response: %w", err)
	}
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
```

## Format, build, and run the example

Format and build it before use:

```bash
gofmt -w examples/core/go/main.go
cd examples/core/go
go test ./...
go build ./...
```

Run the program from the example directory:

```bash
go run . ../../image.png
```

Use a path that points to your actual image. The program accepts JPEG, PNG, GIF, WebP, and AVIF.

## Expected JSON

A new PNG can produce this output after the verification requests succeed:

```json
{
  "hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
  "status": "processing",
  "mime": "image/png",
  "bytes": 71680,
  "width": 1280,
  "height": 720,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}
```

New bytes return HTTP `202`. Identical bytes return HTTP `200` and can include `deduplicated: true`.

The public URL works immediately. It returns a blurred placeholder while the status is `processing`.

## Why the code creates a custom file part

`multipart.Writer` creates the boundary and closing marker. `CreatePart` lets the program set the real image media type.

The server reads the media type from the multipart file part. A generic `application/octet-stream` part would receive HTTP `415`.

`mime.FormatMediaType` safely builds the file part disposition. The field name is exactly `file`.

The 20 MB limit makes an in-memory multipart body practical for this example. Use the service limit before you choose a streaming design.

## How JSON and status checks work

The upload function accepts only HTTP `200` and `202`. It decodes the body into `uploadResponse` and checks required fields.

For non-2xx responses, `responseError` decodes `error` and `fix`. It uses the server instruction when that value is present.

Each JSON decoder has a 1 MB response limit. Upload and error bodies are much smaller than that limit.

## Verify the result and close bodies

`verifyUpload` requests the owner metadata with the bearer key. It confirms that the metadata hash equals the upload hash.

It then requests the public URL without authentication. It requires HTTP `200`, an image content type, and at least one byte.

The upload, metadata, and public HTTP responses each call `defer response.Body.Close()` immediately after a successful request.

The Go `net/http` documentation requires the caller to close each response body. The example follows that rule on success and error paths.

## Recover from API errors

Use the HTTP status, stable error code, and server `fix` value.

| Status | Cause | Action |
| --- | --- | --- |
| `401` | `IMGD_KEY` is missing or invalid. | Load the correct key from the secret manager. Do not print it. |
| `402` | The account is unpaid or lacks quota. | Buy storage or delete unused images. Then run the program again. |
| `413` | The upload is larger than 20 MB. | Resize or compress the image below 20 MB. |
| `415` | The multipart part has an unsupported type. | Use JPEG, PNG, GIF, WebP, or AVIF and set its real media type. |

Do not retry these responses without a change. You can retry a network error with the same bytes. Content addressing returns the same hash and URL.

A valid error body can look like this:

```json
{
  "error": "unsupported_media_type",
  "fix": "'application/octet-stream' is not accepted; send jpeg, png, gif, webp, or avif and set the matching Content-Type",
  "accepted": [
    "image/jpeg",
    "image/png",
    "image/gif",
    "image/webp",
    "image/avif"
  ]
}
```

## Security rules

Keep `IMGD_KEY` in the process environment. Do not put it in a URL, source file, error message, or normal output.

The program prints only the success JSON. That JSON contains a public URL and no key.

Inspect image content before upload. Public storage does not protect private text, customer records, or production data.

## Decide if Go fits

This example fits Go tools that need a standard-library upload client. It also fits callers that require explicit body cleanup and status checks.

It does not fit private images, access control, unsupported files, images above 20 MB, or a free tier. Read the [service evaluation guide](/evaluate.md) first.

## Result

The program builds a valid multipart request, decodes success or error JSON, closes every response body, and verifies the public image.

Compare it with the [Python Requests guide](/blog/upload-image-python-requests/) or the [TypeScript upload guide](/blog/upload-image-typescript/).

Give an agent the [integration procedure](/integrate.md). A person can [start from the home page](/#start).
