Shipping a portable archive
You are building a CLI tool that needs to distribute configuration templates and assets. Scattering files across the user's filesystem is messy and hard to version. You want a single portable artifact that works on Windows, macOS, and Linux. A ZIP archive solves this. Go's standard library includes archive/zip, so you get full ZIP support without external dependencies. The API follows Go's streaming patterns, but it has quirks around buffering, directory structure, and security that trip up beginners.
The ZIP structure and Go's streaming model
A ZIP file is not just a bag of bytes. It has a specific layout. Each file entry contains a local header, the compressed data, and a data descriptor. At the end of the file sits the central directory, which acts as a table of contents. The central directory is mandatory. Without it, the archive is unreadable.
Go treats ZIP archives as streams. The archive/zip package works with io.Writer and io.Reader interfaces. This means you can write a ZIP to a file, a network socket, or a memory buffer using the same code. You can read a ZIP from disk, a database blob, or an HTTP response. The package handles the compression and format details; you handle the data flow.
When you write a ZIP, the writer buffers entries until you call Close. The Close method flushes the central directory to the underlying writer. If you skip Close, the file ends up corrupted because the table of contents is missing.
Always close the writer. A ZIP without a central directory is just garbage bytes.
Creating a minimal archive
Here is the simplest way to create a ZIP file with one entry. The code creates a file on disk, wraps it in a ZIP writer, adds an entry, and closes everything.
package main
import (
"archive/zip"
"fmt"
"os"
)
func main() {
// Create the output file on disk.
file, err := os.Create("hello.zip")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
// Wrap the file in a ZIP writer to handle the archive format.
w := zip.NewWriter(file)
defer w.Close()
// Create an entry named "note.txt" inside the archive.
f, err := w.Create("note.txt")
if err != nil {
fmt.Println(err)
return
}
// Write content to the entry. The writer buffers this until Close is called.
f.Write([]byte("Hello from Go!"))
}
The defer w.Close() runs before defer file.Close() because defers execute in LIFO order. This is critical. The ZIP writer must flush the central directory to the file before the file handle closes. If the order were reversed, the file would close while the writer still held buffers, and the archive would be incomplete.
The w.Create method returns an io.Writer. You write to that writer to add content to the entry. The writer applies compression automatically using the default method. You can change the compression method by setting the Method field on the zip.FileHeader before creating the entry.
Controlling metadata with headers
Real archives need more than just filenames and content. You often need timestamps, permissions, comments, or specific compression settings. The zip.FileHeader struct holds this metadata. You create a header, populate it, and pass it to w.CreateHeader.
Here is how to set a modification time and use stored compression for an entry that is already compressed.
import (
"archive/zip"
"time"
)
func addPreCompressed(w *zip.Writer, name string, data []byte) error {
// Prepare the header with custom metadata.
h := &zip.FileHeader{
Name: name,
Method: zip.Store, // No compression; data is already compressed.
ModTime: time.Now(),
}
// CreateHeader returns a writer for the entry content.
f, err := w.CreateHeader(h)
if err != nil {
return err
}
// Write the pre-compressed bytes directly.
_, err = f.Write(data)
return err
}
The Method field controls compression. zip.Deflate is the default and works for most text and structured data. zip.Store skips compression. Use Store for files that are already compressed, like PNG images or MP3 audio. Compressing already-compressed data wastes CPU time and can increase the file size slightly.
The ModTime field sets the modification timestamp. ZIP tools use this to display file dates. If you omit it, the archive might show a default time like the Unix epoch. Setting accurate timestamps helps users verify the archive contents.
Convention aside: Go functions that accept a context should take context.Context as the first parameter, conventionally named ctx. If you are reading a ZIP from an HTTP request, pass the request context to your handler. The ZIP package itself does not use context, but your surrounding code should respect cancellation.
Reading an archive
Reading a ZIP starts with zip.OpenReader. This function parses the central directory and validates the archive structure. It returns a zip.ReadCloser containing a slice of zip.File entries. You iterate over the entries to find what you need.
Here is how to open a ZIP and list its contents.
import (
"archive/zip"
"fmt"
)
func listEntries(path string) error {
// OpenReader validates the archive and loads the central directory.
r, err := zip.OpenReader(path)
if err != nil {
return err
}
defer r.Close()
// r.File contains every entry, including directories.
for _, f := range r.File {
// Directories are metadata only; skip them for content reading.
if f.FileInfo().IsDir() {
continue
}
fmt.Printf("%s: %d bytes, modified %s\n",
f.Name, f.UncompressedSize64, f.Modified)
}
return nil
}
The r.File slice holds all entries. Each zip.File has a Name field with the path inside the archive. The FileInfo method returns a fs.FileInfo interface, which lets you check if an entry is a directory. Directories in ZIP archives have no content; they exist only to represent folder structure.
To read the actual content, you call f.Open. This returns an io.ReadCloser that decompresses the data on the fly. You must close this reader when done.
import (
"archive/zip"
"io"
"os"
)
func extractEntry(r *zip.ReadCloser, entryName string, destPath string) error {
// Find the entry by name.
var f *zip.File
for _, candidate := range r.File {
if candidate.Name == entryName {
f = candidate
break
}
}
if f == nil {
return fmt.Errorf("entry %q not found", entryName)
}
// Open returns a reader that decompresses data on the fly.
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
// Create the destination file.
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
// Stream the content to disk. Avoid loading large files into memory.
_, err = io.Copy(out, rc)
return err
}
The io.Copy function streams data from the ZIP reader to the destination file. This keeps memory usage constant regardless of file size. If you use io.ReadAll, you load the entire entry into memory. That works for small config files but crashes your program on a 2GB video archive.
Readers decompress on the fly. Don't load a large ZIP into memory.
Security: path traversal attacks
ZIP archives can contain entries with malicious paths. An attacker can craft a ZIP with an entry named ../../etc/passwd. If you extract files using the entry name directly, you might write outside your target directory. This is a path traversal vulnerability.
You must validate and sanitize paths before extracting. Use path.Clean to remove .. segments, and check that the resulting path stays within your intended directory.
import (
"path"
"strings"
)
func safePath(baseDir, entryName string) (string, error) {
// Clean the path to resolve .. and . segments.
cleanName := path.Clean(entryName)
// Reject absolute paths or paths that escape the base directory.
if strings.HasPrefix(cleanName, "/") || strings.HasPrefix(cleanName, "..") {
return "", fmt.Errorf("invalid path: %s", entryName)
}
// Join with the base directory to get the full extraction path.
return path.Join(baseDir, cleanName), nil
}
The path.Clean function normalizes the path. It removes redundant separators and resolves .. components. After cleaning, you check if the path starts with .. or /. If it does, the entry tries to escape the archive. Reject it.
Always validate paths. A malicious ZIP can write files anywhere on your disk.
Pitfalls and compiler errors
The ZIP package has a few common traps.
Forgetting to close the writer corrupts the archive. The compiler won't catch this. You get a runtime error when trying to read the file. The reader fails with archive/zip: invalid zip file with no central directory. Always use defer w.Close() immediately after creating the writer.
Using the wrong error check leads to silent failures. If you ignore the error from w.Create, you might write to a nil writer. The compiler rejects unused variables, but if you assign the error to _, you suppress the problem. The community convention is if err != nil { return err }. The boilerplate makes the unhappy path visible.
Reading a corrupted file panics if you don't check errors. zip.OpenReader returns an error for invalid archives. If you pass that error to a function that expects a valid reader, you might get a nil pointer dereference. Check the error before using the reader.
Passing a *string to a function is unnecessary. Strings are cheap to pass by value in Go. They are immutable and small. Pass the string directly. The same applies to zip.FileHeader. You can pass headers by value unless you need to modify them in place.
When to use ZIP versus alternatives
Go offers several ways to bundle data. Pick the right tool for the job.
Use archive/zip when you need cross-platform compression and standard tooling compatibility. ZIP files open in Windows Explorer, macOS Finder, and Linux tools. They support compression and are the default format for plugins and downloads.
Use archive/tar when you are archiving for Linux systems or Docker images. TAR files preserve Unix permissions and symlinks better than ZIP. Docker images are TAR archives. Use TAR when your audience is infrastructure-focused.
Use embed when your assets are known at compile time and never change. The embed directive compiles files directly into your binary. You get zero runtime overhead and no file I/O. Use embed for static templates, icons, or configuration that ships with the binary.
Use compress/gzip when you need to compress a single stream. GZIP is simpler than ZIP and works well for HTTP responses or single-file backups. ZIP is for multiple files; GZIP is for one.