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
| Approach | Best for | Tradeoff |
|---|---|---|
| archive/zip + archive/tar | Most cases — pure Go, full control, no deps | No support for encrypted ZIP or unusual ZIP variants |
| 7-Zip via os/exec | Encrypted ZIPs, ZIP64, BZIP2-inside-ZIP | ~3MB install (p7zip), subprocess overhead |
| ChangeThisFile API | Skip writing archive code, multi-tenant SaaS | Network 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 thatfilepath.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.