Go has no native H.264 decoder, so MP4-to-GIF in pure Go isn't realistic. The two viable patterns: shell out to FFmpeg (best quality, requires the binary), or call the API. Both produce identical output because the API uses FFmpeg server-side.

Method 1: FFmpeg two-pass palette workflow

The two-pass palettegen + paletteuse workflow is the industry-standard way to produce high-quality GIFs from video. Pass 1 generates an optimal 256-color palette; pass 2 encodes using that palette.

apt install ffmpeg  # or brew install ffmpeg
package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "time"
)

func mp4ToGIF(ctx context.Context, inPath, outPath string, fps, width int, start, duration float64) error {
    tmpDir, err := os.MkdirTemp("", "gif-")
    if err != nil {
        return err
    }
    defer os.RemoveAll(tmpDir)

    palette := filepath.Join(tmpDir, "palette.png")
    filter := fmt.Sprintf("fps=%d,scale=%d:-1:flags=lanczos", fps, width)

    // Pass 1: generate palette
    pass1 := exec.CommandContext(ctx, "ffmpeg", "-y",
        "-ss", fmt.Sprintf("%g", start),
        "-t", fmt.Sprintf("%g", duration),
        "-i", inPath,
        "-vf", filter+",palettegen=stats_mode=diff",
        palette,
    )
    if out, err := pass1.CombinedOutput(); err != nil {
        return fmt.Errorf("palettegen: %w (%s)", err, out)
    }

    // Pass 2: encode GIF using palette
    pass2 := exec.CommandContext(ctx, "ffmpeg", "-y",
        "-ss", fmt.Sprintf("%g", start),
        "-t", fmt.Sprintf("%g", duration),
        "-i", inPath,
        "-i", palette,
        "-lavfi", fmt.Sprintf("%s [v]; [v][1:v] paletteuse=dither=sierra2_4a", filter),
        outPath,
    )
    if out, err := pass2.CombinedOutput(); err != nil {
        return fmt.Errorf("paletteuse: %w (%s)", err, out)
    }
    return nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
    defer cancel()

    if err := mp4ToGIF(ctx, "demo.mp4", "demo.gif", 15, 480, 0, 5); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Three knobs control file size:

  • fps — 12-15 is enough for most clips. Each fps doubling roughly doubles file size.
  • width — 480px is the sweet spot for embeds. Above 720px, file size explodes.
  • duration — keep clips under 10 seconds. Longer than that, switch to MP4 or animated WebP.

The sierra2_4a dither algorithm is a good middle ground; none for sharp graphics, floyd_steinberg for photos.

Method 2: image/gif (stdlib, only for image-source-to-GIF)

Go's stdlib has image/gif for encoding animated GIFs from frames you already have in memory. It can't decode MP4 — you'd need to extract frames first (via FFmpeg) and then encode with image/gif. This is rarely worth doing because FFmpeg can encode GIF directly.

package main

import (
    "image"
    "image/gif"
    "image/png"
    "os"
    "path/filepath"
)

// Assume frames have been extracted to tmpDir/frame-0001.png ... by FFmpeg
func framesToGIF(tmpDir, outPath string) error {
    matches, err := filepath.Glob(filepath.Join(tmpDir, "frame-*.png"))
    if err != nil {
        return err
    }

    out := &gif.GIF{LoopCount: 0}
    for _, p := range matches {
        f, err := os.Open(p)
        if err != nil {
            return err
        }
        img, err := png.Decode(f)
        f.Close()
        if err != nil {
            return err
        }
        // image/gif requires *image.Paletted — convert
        b := img.Bounds()
        pal := image.NewPaletted(b, nil) // global palette would be better
        // (palette quantization omitted — use github.com/ericpauley/go-quantize for production)
        out.Image = append(out.Image, pal)
        out.Delay = append(out.Delay, 7) // 7 = 70ms = ~14fps
    }

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

This pipeline is more code, slower, and produces larger files than method 1. It's only useful when you've already done quality-controlled palette quantization yourself (e.g., with github.com/ericpauley/go-quantize) and want byte-level control over the GIF stream. For 99% of cases, FFmpeg is the right answer.

Method 3: ChangeThisFile API (no FFmpeg dependency)

If you don't want to install FFmpeg (~150MB) or maintain its codec build, the API runs the two-pass palette workflow server-side. 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 mp4ToGIF(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.mp4")
    if err != nil {
        return err
    }
    if _, err := io.Copy(fw, f); err != nil {
        return err
    }
    _ = w.WriteField("source", "mp4")
    _ = w.WriteField("target", "gif")
    _ = 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 := mp4ToGIF("demo.mp4", "demo.gif"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The API uses the same FFmpeg palette-optimization workflow as method 1, so quality is identical. Useful when you don't want FFmpeg in your Docker image.

When to use each

ApproachBest forTradeoff
FFmpeg via os/execSelf-hosted services, max control over flags~150MB install, codec licensing if you redistribute
image/gif (stdlib)You already have decoded frames in memoryLarger files, much more code, no MP4 decode
ChangeThisFile APINo FFmpeg in Docker, edge runtimes, multi-tenant SaaSNetwork call, file size limit (25MB free)

CLI alternative: raw FFmpeg

The Go code above is just FFmpeg with structure. For one-off conversions, the bash version is shorter:

# Pass 1: palette
ffmpeg -y -ss 0 -t 5 -i demo.mp4 \
  -vf "fps=15,scale=480:-1:flags=lanczos,palettegen=stats_mode=diff" palette.png

# Pass 2: encode GIF using palette
ffmpeg -ss 0 -t 5 -i demo.mp4 -i palette.png \
  -lavfi "fps=15,scale=480:-1:flags=lanczos[v];[v][1:v]paletteuse=dither=sierra2_4a" \
  out.gif

Use the bash version for one-offs and the Go version when GIF encoding is part of a larger pipeline.

Production tips

  • Always use CommandContext with a timeout. A stuck FFmpeg process consumes CPU forever. 60-120s is a reasonable bound for typical clips under 10s.
  • Bound concurrency. FFmpeg pegs all CPU cores at full encoding. Limit concurrent conversions to numCPU/2 to keep the host responsive.
  • Consider animated WebP instead. WebP-animated is 25-50% smaller than GIF at the same quality and is supported in every browser. The API supports MP4-to-WebP via target=webp.
  • Pipe stderr only when debugging. CombinedOutput captures both stdout and stderr, but FFmpeg writes its progress meter to stderr — that's a lot of bytes. For production, use cmd.Output() (stdout only) and let stderr go to /dev/null.

Use FFmpeg via os/exec when you control the host. Use the API when you don't want FFmpeg in your container. The output is identical because both use FFmpeg's palette workflow. Free tier covers 1,000 conversions/month.