All posts

gofpdf is archived. Here's how to migrate to gpdf.

jung-kurt/gofpdf was archived in 2021. This guide maps every gofpdf API to gpdf — a pure-Go replacement with native CJK support and zero dependencies.

by gpdf team

TL;DR

gpdf is a pure-Go, zero-dependency PDF library that handles CJK natively (no AddUTF8Font dance), uses a 12-column grid instead of pixel-pushing with SetXY, and runs roughly 10× faster than gofpdf on the same workloads. The migration is mostly about replacing imperative cursor calls with declarative builders. This guide walks the mapping with five before/after pairs.

A teammate opened a fresh Go project last week, ran go get github.com/jung-kurt/gofpdf, and pinged me ten minutes later with a screenshot of the GitHub banner: "This repository has been archived by the owner. It is now read-only." Then a follow-up: "Wait, the fork is archived too?"

Yes. Both of them.

jung-kurt/gofpdf was archived on September 8, 2021. The community fork at go-pdf/fpdf shipped its last release in 2023 and was archived in 2025. The Go PDF library that two thirds of Stack Overflow answers still point to has been read-only for over four years, and the fork that was supposed to replace it is gone too.

If you have a gofpdf codebase in production, this post is a migration map. If you're starting a new project and reflexively reached for gofpdf because that's what the search results showed, this is the alternative.

Why gofpdf is actually staying dead

Open-source libraries don't always die. Sometimes the maintainer steps back and someone else picks it up. That's what most people assumed would happen with gofpdf — and for a while, it did. The community fork at go-pdf/fpdf reorganized the code, fixed a few long-standing bugs, accepted PRs, and felt like a genuine continuation.

Then in early 2025 the fork was archived too. The README now reads, in part: "This project is no longer actively maintained. Consider using a different library."

The reason matters less than the consequence: every Go project that depends on gofpdf is now sitting on two layers of unmaintained code. Security issues won't be patched. The PDF 2.0 spec landed in 2020 and gofpdf still doesn't support most of what changed. Go 1.25's loop variable semantics work fine with gofpdf today, but anything that breaks tomorrow is on you to fix in a fork.

This isn't a "the library has bugs" problem. It's a supply chain problem.

What people actually use gofpdf for

Before getting into the mapping, it helps to be specific about the workloads that actually get migrated. From the issue trackers and Stack Overflow questions on both jung-kurt/gofpdf and go-pdf/fpdf, the dominant uses are:

  1. Invoices and receipts — header, customer block, line items table, totals, footer.
  2. Reports — multi-page documents with repeating headers, page numbers, charts as images.
  3. Forms and certificates — fixed-position text overlaid on a template.
  4. CJK documents — invoices and shipping labels in Japanese, Chinese, Korean.

The first three are well-served by gpdf's builder API. The fourth — CJK — is where gpdf has the largest gap over gofpdf. gofpdf required you to call AddUTF8Font, manage a TTF file path, and hope your text didn't contain characters outside the basic plane. gpdf treats CJK as a first-class case: register a TrueType font, write Japanese, get a PDF.

The API mapping table

The table below is the cheat sheet. Sections after it walk five concrete before/after pairs.

What you want to dogofpdfgpdf
Create a documentgofpdf.New("P", "mm", "A4", "")gpdf.NewDocument(gpdf.WithPageSize(document.A4))
Add a pagepdf.AddPage()doc.AddPage() (returns a *PageBuilder)
Set a fontpdf.SetFont("Arial", "B", 16)template.FontFamily(...), template.Bold(), template.FontSize(16) as text options
Register a TTF (CJK)pdf.AddUTF8Font("noto", "", "NotoSansJP-Regular.ttf")gpdf.WithFont("NotoSansJP", ttfBytes) (at construction)
Write a single linepdf.Cell(40, 10, "hi")c.Text("hi")
Write wrapped textpdf.MultiCell(0, 10, body, "", "L", false)c.Text(body) (wraps automatically)
Set text colorpdf.SetTextColor(255, 0, 0)template.TextColor(pdf.Red) (per-text option)
Draw a horizontal rulepdf.Line(x1, y1, x2, y2)c.Line(template.LineThickness(document.Pt(1)))
Embed an imagepdf.ImageOptions("logo.png", x, y, w, h, ...)c.Image(imgBytes, template.FitWidth(document.Mm(50)))
Set XY cursorpdf.SetXY(x, y)(no equivalent — use rows/cols, or page.Absolute(x, y, fn))
Repeating headerpdf.SetHeaderFunc(fn)doc.Header(fn)
Repeating footerpdf.SetFooterFunc(fn)doc.Footer(fn)
Page numbermanual: pdf.PageNo()c.PageNumber() / c.TotalPages()
Output to filepdf.OutputFileAndClose("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
Output to writerpdf.Output(w)doc.Render(w)

The shape change is the biggest thing: gofpdf is imperative, gpdf is declarative. In gofpdf you push a cursor around the page and write whatever it points at. In gpdf you describe a tree of rows and columns and let the layout engine place things. The first few snippets feel longer in gpdf. By the third you stop missing SetXY.

A note on units. gofpdf lets you pick a base unit at construction ("mm", "pt", "in"). gpdf is always points internally and gives you helpers — document.Mm(20), document.Pt(12), document.Cm(1), document.In(0.5) — for whichever you prefer at the call site. This is closer to CSS than to gofpdf, and once you have a header on every page using document.Mm(15) margins, you stop thinking about it.

Before / After 1: the simplest possible PDF

The "hello world" pair. gofpdf's brevity is what made it so quotable. gpdf's version is a few more lines because it's building a tree, not driving a cursor.

Before — gofpdf:

package main

import "github.com/jung-kurt/gofpdf"

func main() {
    pdf := gofpdf.New("P", "mm", "A4", "")
    pdf.AddPage()
    pdf.SetFont("Arial", "B", 24)
    pdf.Cell(40, 10, "Hello, World!")
    pdf.OutputFileAndClose("hello.pdf")
}

After — gpdf:

package main

import (
    "log"
    "os"

    "github.com/gpdf-dev/gpdf"
    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("Hello, World!", template.FontSize(24), template.Bold())
        })
    })

    data, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

The grid does the work. AutoRow adds a row whose height is determined by its content; r.Col(12, ...) says "this column spans all 12 grid columns." Same idea as Bootstrap, applied to a PDF page.

Generate returns the bytes; Render(w) streams to an io.Writer if you'd rather not allocate. There's no "close the file" step because gpdf doesn't own a file handle.

Before / After 2: a table of line items

Tables are where gofpdf gets verbose. There's no built-in table; you call Cell in nested loops, manage your own column widths, and do Ln(-1) to move to the next row. Half the gofpdf invoice tutorials on the internet are mostly table boilerplate.

Before — gofpdf:

pdf.SetFont("Arial", "B", 11)
pdf.SetFillColor(220, 220, 220)
pdf.CellFormat(80, 8, "Description", "1", 0, "L", true, 0, "")
pdf.CellFormat(20, 8, "Qty",         "1", 0, "C", true, 0, "")
pdf.CellFormat(30, 8, "Unit",        "1", 0, "R", true, 0, "")
pdf.CellFormat(30, 8, "Amount",      "1", 1, "R", true, 0, "")

pdf.SetFont("Arial", "", 11)
items := [][]string{
    {"Frontend dev", "40 hrs", "$150.00", "$6,000.00"},
    {"Backend dev",  "60 hrs", "$150.00", "$9,000.00"},
    {"UI design",    "20 hrs", "$120.00", "$2,400.00"},
}
for _, row := range items {
    pdf.CellFormat(80, 8, row[0], "1", 0, "L", false, 0, "")
    pdf.CellFormat(20, 8, row[1], "1", 0, "C", false, 0, "")
    pdf.CellFormat(30, 8, row[2], "1", 0, "R", false, 0, "")
    pdf.CellFormat(30, 8, row[3], "1", 1, "R", false, 0, "")
}

Counts widths in your head, and good luck if a description wraps.

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"Description", "Qty", "Unit", "Amount"},
            [][]string{
                {"Frontend dev", "40 hrs", "$150.00", "$6,000.00"},
                {"Backend dev",  "60 hrs", "$150.00", "$9,000.00"},
                {"UI design",    "20 hrs", "$120.00", "$2,400.00"},
            },
            template.ColumnWidths(50, 15, 15, 20),
            template.TableHeaderStyle(
                template.Bold(),
                template.TextColor(pdf.White),
                template.BgColor(pdf.RGBHex(0x1A237E)),
            ),
            template.TableStripe(pdf.RGBHex(0xF5F5F5)),
        )
    })
})

ColumnWidths(50, 15, 15, 20) is percentages of the column the table lives in, not absolute millimeters. Drop the table into a r.Col(6, ...) and the same percentages still work. That's the kind of thing you can't get out of CellFormat without a wrapper.

Wrapping is automatic. Page breaks are automatic — if the table runs past the bottom margin, the header repeats on the next page.

Before / After 3: Japanese text without the dance

This is the one that pushed me off gofpdf. To render Japanese in gofpdf you call AddUTF8Font, point it at a TTF on disk, set the font, and pray. Subsetting works most of the time. Some TTFs trigger glyph-id collisions and emit garbled output. The error messages don't help.

Before — gofpdf:

pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddUTF8Font("notosansjp", "", "NotoSansJP-Regular.ttf")
pdf.AddPage()
pdf.SetFont("notosansjp", "", 14)
pdf.Cell(0, 10, "こんにちは、世界。")
pdf.OutputFileAndClose("ja.pdf")

Two land mines: the TTF must exist at the path you give, at runtime, on the host that runs your binary (so your Docker image has to ship the font). And Cell of "0" width means "to the right margin," which for CJK text often clips because the width estimator doesn't account for full-width glyphs correctly.

After — gpdf:

package main

import (
    "log"
    "os"

    "github.com/gpdf-dev/gpdf"
    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    fontData, err := os.ReadFile("NotoSansJP-Regular.ttf")
    if err != nil {
        log.Fatal(err)
    }

    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
        gpdf.WithFont("NotoSansJP", fontData),
        gpdf.WithDefaultFont("NotoSansJP", 14),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("こんにちは、世界。")
            c.Text("吾輩は猫である。名前はまだ無い。")
            c.Text("東京都渋谷区神宮前1-2-3")
        })
    })

    data, _ := doc.Generate()
    os.WriteFile("ja.pdf", data, 0o644)
}

Two things are different.

First, you pass bytes, not a path. Embed the TTF with //go:embed and your binary becomes self-contained. No "font not found" in production because someone forgot to mount a volume.

Second, gpdf's TrueType subsetter understands CJK cmap formats (4, 6, 12) and Identity-H encoding. The output PDF only carries the glyphs you actually used — embedding NotoSansJP for a 200-character invoice produces a ~30 KB font subset, not a 4 MB full embed. If you've ever watched gofpdf write a 5 MB PDF for one page of Japanese, this is the thing you'll notice first.

For a deeper walkthrough of CJK-specific options — IPAex Gothic, Source Han Sans, fallback chains — see the upcoming companion post on Japanese fonts.

The gofpdf pattern for repeating chrome is SetHeaderFunc and SetFooterFunc — both take a func() that runs against the current cursor. Page numbers come from pdf.PageNo() and pdf.AliasNbPages().

Before — gofpdf:

pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetHeaderFunc(func() {
    pdf.SetFont("Arial", "B", 12)
    pdf.Cell(0, 10, "ACME Corporation")
    pdf.Ln(15)
})
pdf.SetFooterFunc(func() {
    pdf.SetY(-15)
    pdf.SetFont("Arial", "I", 8)
    pdf.CellFormat(0, 10,
        fmt.Sprintf("Page %d/{nb}", pdf.PageNo()),
        "", 0, "C", false, 0, "")
})
pdf.AliasNbPages("")
pdf.AddPage()
// ... body ...

{nb} is a sentinel that gofpdf rewrites at output time with the total page count. It works, it's just one of those things you have to know.

After — gpdf:

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)

doc.Header(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("ACME Corporation", template.Bold(), template.FontSize(12))
            c.Line(template.LineColor(pdf.Gray(0.7)))
            c.Spacer(document.Mm(4))
        })
    })
})

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME Corporation",
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
        r.Col(6, func(c *template.ColBuilder) {
            // "Page X / Y" — both pieces are placeholders resolved
            // by the layout engine after pagination.
            c.PageNumber(template.AlignRight(),
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
    })
})

for i := 0; i < 10; i++ {
    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text(fmt.Sprintf("Body content for page %d.", i+1))
        })
    })
}

PageNumber and TotalPages are placeholders. They get expanded after pagination, when the layout engine knows how many pages exist. No {nb} sentinel, no manual SetY(-15) to peg the footer to the bottom — the footer is just a tree, and the engine reserves space for it on every page automatically.

Before / After 5: producing bytes for an HTTP handler

Most production gofpdf code doesn't write to a file. It writes to an io.Writer — usually a http.ResponseWriter returning application/pdf to a browser. This is the pair where gpdf's API is closest to gofpdf's.

Before — gofpdf:

func handler(w http.ResponseWriter, r *http.Request) {
    pdf := gofpdf.New("P", "mm", "A4", "")
    pdf.AddPage()
    pdf.SetFont("Arial", "", 12)
    pdf.Cell(0, 10, "Generated at "+time.Now().Format(time.RFC3339))

    w.Header().Set("Content-Type", "application/pdf")
    if err := pdf.Output(w); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

After — gpdf:

func handler(w http.ResponseWriter, r *http.Request) {
    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("Generated at " + time.Now().Format(time.RFC3339))
        })
    })

    w.Header().Set("Content-Type", "application/pdf")
    if err := doc.Render(w); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

Same shape. doc.Render(w) streams the PDF directly into the response. If you want to set Content-Length, call Generate() first to get the byte slice, then len() it.

How fast is "fast enough"?

gpdf is roughly 10× faster than gofpdf on the workloads people actually run. The numbers below are from _benchmark/benchmark_test.go on an Apple M1 with Go 1.25.

BenchmarkgpdfgofpdfgopdfMaroto v2
Single page13 µs132 µs423 µs237 µs
4×10 table108 µs241 µs835 µs8.6 ms
100-page document683 µs11.7 ms8.6 ms19.8 ms
Complex CJK invoice133 µs254 µs997 µs10.4 ms

These aren't synthetic — the table benchmark is a 4-column, 10-row invoice line-items table; the 100-page benchmark is a paginated report with a repeating header and page numbers. The shape matches what production code actually does.

A small note on what these numbers mean. At 13 µs per single page, a single core can produce ~75,000 hello-world PDFs per second. At 108 µs per table-rich page, ~9,000 invoices per second. The point isn't bragging rights — it's that you can stop thinking about whether to cache or async-queue PDF generation. For most workloads, generating on the request path is fine.

What you're giving up

Nothing in this guide is interesting if it papers over real gaps. Here's what gpdf doesn't do that gofpdf does:

  • Arbitrary line angles, beziers, and complex paths. c.Line() draws a horizontal rule across a column. If you're producing technical drawings or charts with custom geometry, gpdf isn't there yet. (Charts as pre-rendered images: works fine.)
  • SetXY and absolute cursor work. You can do absolute positioning with page.Absolute(x, y, fn), but if your existing code is 2,000 lines of SetXY followed by Cell, the migration is more like a rewrite. The upside is the rewritten code is usually half the size.
  • Form fields (AcroForm). gpdf doesn't yet generate fillable form fields. If your PDFs are form templates that users fill in a viewer, stay on a library that supports AcroForm — for now.
  • Annotations and bookmarks. Basic outline support exists; rich annotations don't.

If none of those bite, the migration is straight-through. If one of them does, file an issue — the roadmap is driven by what people ask for.

FAQ

Is gpdf a fork of gofpdf? No. gpdf is a clean reimplementation. The PDF wire-format work, the layout engine, the TrueType subsetter — all written from scratch in pure Go. There's no shared lineage with gofpdf or its forks. The reason it has to be a clean rewrite is that gofpdf's architecture is built around a single mutable cursor; you can't get a declarative grid out of that without breaking every existing call site.

Does gpdf have any external dependencies? The core library has zero. Run go mod graph | grep gpdf after go get github.com/gpdf-dev/gpdf and you'll see one line. The gpdf-pro add-on (HTML→PDF, AES encryption, signatures, PDF/A) pulls in golang.org/x/net for HTML parsing, but that's opt-in and not required for migration.

What about CGO? gofpdf was CGO-free, what about gpdf? Same. Pure Go, no CGO. Cross-compile with GOOS=linux GOARCH=arm64 go build and ship a static binary. This matters for distroless and Alpine images, where dragging in a CGO toolchain doubles your container size.

My existing gofpdf code uses SetXY for absolute positioning everywhere. Can I migrate without rewriting? You can wrap page.Absolute(x, y, fn) and get something that feels similar. But if your code is structured around cursor manipulation, the layout-engine model is a mental shift, not a syntactic one. Read the 12-column grid post (link to come) before estimating the work — most teams find the rewrite is shorter than the original.

What if go-pdf/fpdf gets unarchived? Then you have one more option. The bet behind gpdf isn't that gofpdf will stay archived forever — it's that the architecture (cursor-based, single-byte fonts, no native CJK) is a dead end regardless of who maintains it. PDF generation in 2026 looks more like building a web page than driving a plotter, and the API should reflect that.

Try gpdf

gpdf is a Go library for generating PDFs. MIT licensed, zero external dependencies, native CJK support.

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · Read the docs

Next reads

  • How does the 12-column grid work in gpdf? (coming soon)
  • How do I embed a Japanese font in gpdf? (coming soon)
  • Quickstart — five-minute setup, including go.mod