Both ZIP and TAR are well-supported in Go's stdlib. The conversion is essentially: read entries from ZIP, write entries to TAR. Catches: file mode (Unix permissions), symlinks (TAR has them, ZIP weakly), and gzip wrapping (.tar.gz vs .tar). The stdlib handles all of these with care.

Method 1: archive/zip + archive/tar (stdlib only)

Both packages are in the stdlib. Convert by walking ZIP entries and writing them to a TAR writer.

package main

import (
    "archive/tar"
    "archive/zip"
    "fmt"
    "io"
    "os"
)

func zipToTar(inPath, outPath string) error {
    zr, err := zip.OpenReader(inPath)
    if err != nil {
        return fmt.Errorf("open zip: %w", err)
    }
    defer zr.Close()

    out, err := os.Create(outPath)
    if err != nil {
        return err
    }
    defer out.Close()

    tw := tar.NewWriter(out)
    defer tw.Close()

    for _, zf := range zr.File {
        info := zf.FileInfo()

        hdr, err := tar.FileInfoHeader(info, "")
        if err != nil {
            return fmt.Errorf("header %s: %w", zf.Name, err)
        }
        hdr.Name = zf.Name // preserve directory structure

        if err := tw.WriteHeader(hdr); err != nil {
            return err
        }

        if !info.IsDir() {
            rc, err := zf.Open()
            if err != nil {
                return fmt.Errorf("open entry %s: %w", zf.Name, err)
            }
            if _, err := io.Copy(tw, rc); err != nil {
                rc.Close()
                return fmt.Errorf("copy %s: %w", zf.Name, err)
            }
            rc.Close()
        }
    }

    return nil
}

func main() {
    if err := zipToTar("archive.zip", "archive.tar"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println("converted")
}

Three things to know:

  • tar.FileInfoHeader handles modes. It pulls Unix permissions from the ZIP entry's external attributes. ZIP files created on Windows usually have 0644 by default.
  • defer rc.Close() inside a loop is a leak. The above explicitly Closes inside the loop body — using defer in the loop would defer all closes to function return.
  • archive/tar respects long filenames. Names over 100 chars get the GNU TAR extended header automatically.

Method 2: ZIP to .tar.gz (gzip wrapper)

TAR alone is uncompressed. The common Unix archive is .tar.gz. Wrap the tar.Writer in a gzip.Writer:

package main

import (
    "archive/tar"
    "archive/zip"
    "compress/gzip"
    "fmt"
    "io"
    "os"
)

func zipToTarGz(inPath, outPath string) error {
    zr, err := zip.OpenReader(inPath)
    if err != nil {
        return err
    }
    defer zr.Close()

    out, err := os.Create(outPath)
    if err != nil {
        return err
    }
    defer out.Close()

    // Wrap output in gzip
    gz := gzip.NewWriter(out)
    defer gz.Close()

    tw := tar.NewWriter(gz)
    defer tw.Close()

    for _, zf := range zr.File {
        info := zf.FileInfo()
        hdr, err := tar.FileInfoHeader(info, "")
        if err != nil {
            return err
        }
        hdr.Name = zf.Name

        if err := tw.WriteHeader(hdr); err != nil {
            return err
        }

        if !info.IsDir() {
            rc, err := zf.Open()
            if err != nil {
                return err
            }
            _, err = io.Copy(tw, rc)
            rc.Close()
            if err != nil {
                return fmt.Errorf("copy %s: %w", zf.Name, err)
            }
        }
    }
    return nil
}

func main() {
    if err := zipToTarGz("archive.zip", "archive.tar.gz"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The order is critical: os.File <- gzip.Writer <- tar.Writer. Closing matters too — tar must close before gzip (which writes its checksum), which must close before the file. The defer order above (LIFO) does this correctly.

Method 3: ChangeThisFile API (no archive code)

If you want to skip writing archive code (or the input ZIP has features the stdlib doesn't handle — encryption, ZIP64, BZIP2 inside), the API does it. Free tier covers 1,000 conversions/month.

package main

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

const apiKey = "ctf_sk_your_key_here"

func zipToTar(inPath, outPath string) error {
    body := &bytes.Buffer{}
    w := multipart.NewWriter(body)

    f, err := os.Open(inPath)
    if err != nil {
        return err
    }
    defer f.Close()

    fw, err := w.CreateFormFile("file", "input.zip")
    if err != nil {
        return err
    }
    if _, err := io.Copy(fw, f); err != nil {
        return err
    }
    _ = w.WriteField("source", "zip")
    _ = w.WriteField("target", "tar")
    _ = w.Close()

    req, err := http.NewRequest("POST", "https://changethisfile.com/v1/convert", body)
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", w.FormDataContentType())

    client := &http.Client{Timeout: 120 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        msg, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("api %d: %s", resp.StatusCode, msg)
    }

    out, err := os.Create(outPath)
    if err != nil {
        return err
    }
    defer out.Close()
    _, err = io.Copy(out, resp.Body)
    return err
}

func main() {
    if err := zipToTar("archive.zip", "archive.tar"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

For .tar.gz output, set target=tar.gz. For .tar.xz (better compression), target=tar.xz. The API uses 7-Zip server-side which handles every reasonable archive variant.

When to use each

ApproachBest forTradeoff
archive/zip + archive/tarMost cases — pure Go, full control, no depsNo support for encrypted ZIP or unusual ZIP variants
7-Zip via os/execEncrypted ZIPs, ZIP64, BZIP2-inside-ZIP~3MB install (p7zip), subprocess overhead
ChangeThisFile APISkip writing archive code, multi-tenant SaaSNetwork call, file size limit (25MB free)

CLI alternative: 7z, unzip + tar

For one-off conversions:

# 7-Zip (single command for any archive)
apt install p7zip-full
7z x archive.zip -otmp/   # extract
tar -czvf archive.tar.gz -C tmp .  # re-archive

# Or pipe (loses metadata):
# unzip + tar (no extras needed)
mkdir tmp && cd tmp && unzip ../archive.zip && tar -czvf ../archive.tar.gz . && cd ..
rm -rf tmp
// From Go, the stdlib approach is usually simpler than shelling out:
cmd := exec.Command("7z", "x", "archive.zip", "-otmp/")
cmd.Run()
cmd2 := exec.Command("tar", "-czvf", "archive.tar.gz", "-C", "tmp", ".")
cmd2.Run()

The shell version requires a temp directory; the Go stdlib version converts in-memory (or streaming). For long-running services, prefer stdlib.

Production tips

  • Stream don't buffer. The stdlib examples above stream data through io.Copy — never buffer entire entries in memory. A 4GB ZIP entry would OOM your service if buffered.
  • Watch for path traversal. A malicious ZIP can have entries named ../../etc/passwd. If you're extracting (not converting), validate that filepath.Clean(entry.Name) doesn't escape your output dir.
  • Decide on compression level. gzip.NewWriter uses default level 6. For smaller files: gzip.NewWriterLevel(out, gzip.BestCompression) (slower). For speed: gzip.BestSpeed.
  • Preserve mtime. tar.FileInfoHeader copies mtime from the ZIP entry's modified date. Some Windows tools don't set ZIP mtimes correctly — verify if metadata matters.
  • For .tar.xz, use github.com/ulikunitz/xz. Pure Go, slower than gzip but ~30% better compression for text content. Stdlib doesn't include xz.

For most Go services, the stdlib does the job in ~30 lines. For encrypted or exotic archives, shell out to 7-Zip or use the API. Free tier covers 1,000 conversions/month.