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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
List all Go tutorials.