ZetCode

Go HTTP serve image

last modified October 30, 2026

In this article we show how to serve an image from a Go HTTP server. We cover the correct content type, streaming from disk, downloads, range requests, caching with an ETag, validation before sending, and serving an embedded image from the binary.

The examples use a small PNG file that is generated by the first program, so every response can be checked byte for byte. All programs were run with Go 1.27.0.

Generating the image

The file sid.png is a 120 by 80 pixel image. The program builds it with image.NewRGBA and writes it with png.Encode. The colours depend on the coordinates, which makes the image easy to recognise without shipping a binary file.

main.go
package main

import (
    "fmt"
    "image"
    "image/color"
    "image/png"
    "log"
    "os"
)

func main() {

    img := image.NewRGBA(image.Rect(0, 0, 120, 80))

    for y := 0; y < 80; y++ {
        for x := 0; x < 120; x++ {
            img.Set(x, y, color.RGBA{uint8(x * 2), uint8(y * 3), 200, 255})
        }
    }

    f, err := os.Create("sid.png")
    if err != nil {
        log.Fatal(err)
    }

    defer f.Close()

    if err := png.Encode(f, img); err != nil {
        log.Fatal(err)
    }

    info, err := f.Stat()
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("wrote sid.png:", info.Size(), "bytes")
}

The encoded file is 220 bytes. A solid colour would compress better, but the gradient produces a recognizable picture and a stable file size.

$ go run main.go
wrote sid.png: 220 bytes

Serving with http.ServeFile

http.ServeFile is the shortest correct way to send a file. It sets the content type from the extension, supports range requests and conditional requests, and answers 404 when the file does not exist.

main.go
package main

import (
    "log"
    "net/http"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "sid.png")
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The response has Content-Type: image/png because of the file extension, and Accept-Ranges: bytes announces that partial requests are supported. The client receives exactly the bytes of the file.

$ curl -sI localhost:8080/image | grep -iE 'HTTP/|content-type|content-length|accept-ranges'
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 220
Content-Type: image/png

Reading the file into memory

A handler can also read the file and write it to the response itself. This is useful when the bytes have to be processed first, but it holds the whole image in memory for every request.

main.go
package main

import (
    "log"
    "net/http"
    "os"
    "strconv"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {

        buf, err := os.ReadFile("sid.png")
        if err != nil {
            http.Error(w, "image not found", http.StatusNotFound)
            return
        }

        w.Header().Set("Content-Type", "image/png")
        w.Header().Set("Content-Length", strconv.Itoa(len(buf)))

        w.Write(buf)
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The content type is set explicitly, because Go would otherwise sniff it from the first bytes. The downloaded file is identical to the original, which the hashes confirm.

$ curl -s -o copy.png localhost:8080/image
$ sha256sum sid.png copy.png
850b1d62990a6767a45199cc5e8d904c9526c3dc92869d86c3346e071a11e644  sid.png
850b1d62990a6767a45199cc5e8d904c9526c3dc92869d86c3346e071a11e644  copy.png

Streaming the file with io.Copy

For large images it is better to stream the file with io.Copy instead of loading it into a byte slice. The size comes from Stat, so the response still has a Content-Length.

main.go
package main

import (
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {

        f, err := os.Open("sid.png")
        if err != nil {
            http.Error(w, "image not found", http.StatusNotFound)
            return
        }
        defer f.Close()

        info, err := f.Stat()
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "image/png")
        w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))

        if _, err := io.Copy(w, f); err != nil {
            log.Println(err)
        }
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

io.Copy reads the file in chunks and writes each chunk to the response writer, so the memory use does not grow with the file size. The handler writes 220 bytes and the checksum still matches.

$ curl -s -o copy.png -w '%{http_code} %{size_download}\n' localhost:8080/image
200 220
$ sha256sum sid.png copy.png
850b1d62990a6767a45199cc5e8d904c9526c3dc92869d86c3346e071a11e644  sid.png
850b1d62990a6767a45199cc5e8d904c9526c3dc92869d86c3346e071a11e644  copy.png

Sending the image as an attachment

The Content-Disposition header with the attachment type asks the browser to download the file instead of displaying it. The filename parameter suggests the name to save it under.

main.go
package main

import (
    "log"
    "net/http"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Disposition", `attachment; filename="sid.png"`)
        http.ServeFile(w, r, "sid.png")
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The header must be set before ServeFile writes the response. A filename containing non-ASCII characters needs the extended filename* form with a character set, which is beyond this example.

$ curl -sI localhost:8080/image | grep -i content-disposition
Content-Disposition: attachment; filename="sid.png"

Embedding the image in an HTML page

An image is usually served next to an HTML page that references it. The page is returned for /, and the file server mounted at /static/ returns the PNG for the img element.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        fmt.Fprint(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head><title>Sid</title></head>\n<body>\n<p>Sid the sloth</p>\n<img src=\"/static/sid.png\" alt=\"Sid the sloth\">\n</body>\n</html>\n")
    })

    http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The /static/ prefix is removed before the file server looks up the path, so /static/sid.png maps to public/sid.png. The file server sets the image content type from the extension.

$ curl -s localhost:8080/
<!DOCTYPE html>
<html lang="en">
<head><title>Sid</title></head>
<body>
<p>Sid the sloth</p>
<img src="/static/sid.png" alt="Sid the sloth">
</body>
</html>
$ curl -sI localhost:8080/static/sid.png | grep -iE 'HTTP/|content-type|content-length'
HTTP/1.1 200 OK
Content-Length: 220
Content-Type: image/png

Range requests

ServeFile supports the Range header, which clients use to resume an interrupted download or to fetch only the first bytes of an image. The response is 206 Partial Content with a Content-Range header.

main.go
package main

import (
    "log"
    "net/http"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {
        http.ServeFile(w, r, "sid.png")
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The client asks for bytes 0 to 9 and receives ten bytes. A handler that writes the file manually, like the io.Copy version above, does not get this behaviour for free.

$ curl -s -r 0-9 -o part.bin -w '%{http_code} %{size_download}\n' localhost:8080/image
206 10
$ curl -sI -H 'Range: bytes=0-9' localhost:8080/image | grep -iE 'HTTP/|content-range|content-length'
HTTP/1.1 206 Partial Content
Content-Length: 10
Content-Range: bytes 0-9/220

Caching with an ETag

An ETag is an identifier for a specific version of a resource. The handler computes it from the file contents and answers a request that carries a matching If-None-Match header with 304 Not Modified and no body.

main.go
package main

import (
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {

        buf, err := os.ReadFile("sid.png")
        if err != nil {
            http.Error(w, "image not found", http.StatusNotFound)
            return
        }

        sum := sha256.Sum256(buf)
        etag := fmt.Sprintf("%q", hex.EncodeToString(sum[:8]))

        w.Header().Set("ETag", etag)
        w.Header().Set("Cache-Control", "public, max-age=3600")

        if r.Header.Get("If-None-Match") == etag {
            w.WriteHeader(http.StatusNotModified)
            return
        }

        w.Header().Set("Content-Type", "image/png")
        w.Write(buf)
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The ETag is the first eight bytes of the SHA-256 hash, quoted as the HTTP specification requires. Go writes the header name in canonical form, so the response shows Etag. The cache may keep the image for an hour.

$ curl -sI localhost:8080/image | grep -iE 'HTTP/|etag|cache-control'
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Etag: "850b1d62990a6767"
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'If-None-Match: "850b1d62990a6767"' localhost:8080/image
304

Validating the file before sending it

image.DecodeConfig reads only the image header and returns the format and the dimensions. A handler can use it to reject files that are not images before any pixels are decoded.

main.go
package main

import (
    "fmt"
    "image"
    _ "image/png"
    "log"
    "net/http"
    "os"
    "path/filepath"
)

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {

        name := filepath.Base(r.URL.Query().Get("file"))

        f, err := os.Open(name)
        if err != nil {
            http.Error(w, "file not found", http.StatusNotFound)
            return
        }
        defer f.Close()

        cfg, format, err := image.DecodeConfig(f)
        if err != nil {
            http.Error(w, "not an image", http.StatusUnsupportedMediaType)
            return
        }

        w.Header().Set("X-Image", fmt.Sprintf("%s %dx%d", format, cfg.Width, cfg.Height))
        http.ServeFile(w, r, name)
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

filepath.Base keeps the filename inside the working directory: a value such as ../../etc/passwd becomes passwd. The blank import registers the PNG decoder, and a text file produces a 415 response.

$ curl -sI 'localhost:8080/image?file=sid.png' | grep -iE 'HTTP/|x-image|content-type'
HTTP/1.1 200 OK
Content-Type: image/png
X-Image: png 120x80
$ curl -s -w ' [%{http_code}]\n' 'localhost:8080/image?file=note.txt'
not an image
 [415]

Serving an embedded image

The embed package puts the file into the binary at build time. http.ServeContent serves the bytes from memory and keeps the content type and range handling of a file server.

main.go
package main

import (
    "bytes"
    _ "embed"
    "log"
    "net/http"
    "time"
)

//go:embed sid.png
var image []byte

func main() {

    http.HandleFunc("/image", func(w http.ResponseWriter, r *http.Request) {
        http.ServeContent(w, r, "sid.png", time.Time{}, bytes.NewReader(image))
    })

    log.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The file name passed to ServeContent is used for the content type, and the zero modification time disables the Last-Modified header. The program no longer needs the PNG at run time.

$ curl -sI localhost:8080/image | grep -iE 'HTTP/|content-type|content-length|accept-ranges'
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 220
Content-Type: image/png
$ curl -s -o copy.png localhost:8080/image
$ sha256sum sid.png copy.png
850b1d62990a6767a45199cc5e8d904c9526c3dc92869d86c3346e071a11e644  sid.png
850b1d62990a6767a45199cc5e8d904c9526c3dc92869d86c3346e071a11e644  copy.png

Source

Go net/http package - reference
Go image/png package - reference

In this article we have served images from a Go HTTP server: with ServeFile, from memory, by streaming, as a download, inside an HTML page, with range requests, with an ETag, after validation, and from an embedded file.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.