PNG-to-WebP in Go has no pure-Go path for encoding — every viable library wraps libwebp or libvips. The choice is mostly about deployment: do you want native deps in your binary, or do you call out to a service? Once you have libwebp installed, performance is excellent.

Method 1: chai2010/webp (libwebp via cgo)

chai2010/webp is the most-used WebP encoder in Go. It wraps Google's libwebp via cgo with a clean image.Image interface.

apt install libwebp-dev  # or brew install webp
go get github.com/chai2010/webp
package main

import (
    "fmt"
    "image/png"
    "os"

    "github.com/chai2010/webp"
)

func pngToWebP(inPath, outPath string, quality float32, lossless bool) error {
    in, err := os.Open(inPath)
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    defer in.Close()

    img, err := png.Decode(in)
    if err != nil {
        return fmt.Errorf("decode: %w", err)
    }

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

    opts := &webp.Options{Quality: quality, Lossless: lossless}
    return webp.Encode(out, img, opts)
}

func main() {
    if err := pngToWebP("hero.png", "hero.webp", 85, false); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if err := pngToWebP("diagram.png", "diagram.webp", 0, true); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Two knobs that matter:

  • Quality (0-100, float32) — 85 is the sweet spot for photos. Below 70 you'll see banding.
  • Lossless — true for diagrams, screenshots, sharp-edge graphics. Quality is ignored when lossless is set.

cgo means you need a C compiler at build time and libwebp on the target system. For Lambda/Alpine, this often means adding gcc and musl-dev to the build image.

Method 2: govips (libvips via cgo, fastest)

govips wraps libvips — the same engine sharp uses. It's 5-10x faster than libwebp alone and handles batch pipelines elegantly.

apt install libvips-dev  # or brew install vips
go get github.com/davidbyttow/govips/v2/vips
package main

import (
    "fmt"
    "os"

    "github.com/davidbyttow/govips/v2/vips"
)

func pngToWebP(inPath, outPath string, quality int, lossless bool) error {
    img, err := vips.NewImageFromFile(inPath)
    if err != nil {
        return fmt.Errorf("load: %w", err)
    }
    defer img.Close()

    params := vips.NewWebpExportParams()
    params.Quality = quality
    params.Lossless = lossless
    params.ReductionEffort = 6 // 0-6, higher = smaller files

    bytes, _, err := img.ExportWebp(params)
    if err != nil {
        return fmt.Errorf("export: %w", err)
    }

    return os.WriteFile(outPath, bytes, 0o644)
}

func main() {
    vips.Startup(nil)
    defer vips.Shutdown()

    if err := pngToWebP("hero.png", "hero.webp", 85, false); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

libvips is the fastest option in the Go ecosystem. ReductionEffort=6 is the slowest but smallest; for hot paths use 4 (default) for the speed/size tradeoff. Always call vips.Startup once at process start and Shutdown at exit.

Method 3: ChangeThisFile API (no cgo, no native libs)

If you can't use cgo (Lambda without custom layers, distroless containers, edge runtimes), the API does it. Free tier covers 1,000 conversions/month.

package main

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

const apiKey = "ctf_sk_your_key_here"

func pngToWebP(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.png")
    if err != nil {
        return err
    }
    if _, err := io.Copy(fw, f); err != nil {
        return err
    }
    _ = w.WriteField("source", "png")
    _ = w.WriteField("target", "webp")
    _ = 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())

    resp, err := http.DefaultClient.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 := pngToWebP("hero.png", "hero.webp"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The API runs libvips server-side. Quality is identical to method 2 without your binary needing to link libvips.

When to use each

ApproachBest forTradeoff
chai2010/webpSingle image conversions, simple setupcgo + libwebp dependency
govipsBatch jobs, max performance, image pipelinescgo + libvips (~30MB), Startup/Shutdown lifecycle
ChangeThisFile APILambda, distroless, edge runtimesNetwork call, file size limit (25MB free)

CLI alternative: cwebp

For one-off conversions or shell pipelines, Google's cwebp binary is the simplest option:

apt install webp  # provides cwebp
cwebp -q 85 -m 6 hero.png -o hero.webp
cwebp -lossless diagram.png -o diagram.webp

# Batch with find:
find . -name "*.png" -exec sh -c 'cwebp -q 85 "$1" -o "${1%.png}.webp"' _ {} \;
// From Go, shell out via os/exec:
cmd := exec.Command("cwebp", "-q", "85", "-m", "6", "hero.png", "-o", "hero.webp")
out, err := cmd.CombinedOutput()

cwebp uses the same encoder as chai2010/webp (libwebp), so quality is identical. Use cwebp for ad-hoc conversions; use chai2010 or govips when WebP encoding is part of a larger Go pipeline.

Production tips

  • For govips, manage the lifecycle. Call vips.Startup(nil) once at process start, vips.Shutdown() on exit. Forgetting Startup means leaks; forgetting Shutdown leaves cached threads.
  • Use Quality=85 as the default. Below 70 you'll see banding on photos. Above 90 you get little visible gain at much larger file sizes.
  • Lossless for diagrams, lossy for photos. A 1MB PNG diagram can become a 100KB lossless WebP. The same diagram at quality=85 lossy might be larger because lossy encoding penalizes hard edges.
  • For batch jobs, parallelize with bounded concurrency. libvips releases the goroutine, so a worker pool with numCPU workers maxes out the host without OOM.
  • Strip metadata for web-served images. EXIF and ICC profiles add KB. govips: img.RemoveMetadata(); chai2010: re-encode without metadata.

For most Go services, chai2010/webp with quality=85 is the right default. For high-throughput batch jobs, govips is significantly faster. For environments where cgo is a hassle, the API. Free tier covers 1,000 conversions/month at 25MB max per file.