Generate an invoice PDF in Go in under 50 lines
A complete, runnable invoice PDF in Go — 50 lines with gpdf, zero dependencies, no Chromium, no CGO. Here's the code and what every block does.
TL;DR
A working invoice PDF in Go, end-to-end, in 50 lines. One main.go, one go get, no Chromium, no CGO, no templating language, no HTML. Table, striped rows, right-aligned totals. It runs. The code is below, and the rest of this post is what each block does and where the pattern stops scaling.
If you just want to read the code first:
go get github.com/gpdf-dev/gpdf
Then paste main.go from the next section.
Why "under 50 lines" is the threshold we care about
The honest reason this post exists: most people Googling "generate invoice pdf in go" find blog posts that either (a) recommend spawning headless Chromium, or (b) show 400 lines of low-level PDF operators to render a single table. Both answers are technically correct. Neither is what the task is.
A reasonable invoice has:
- A header with your company and the customer's
- An invoice number and a due date
- A line-item table
- A total
That's four things. It should be four blocks of code. If it takes more than one screen, the library is wrong.
50 lines is roughly the limit where the code still fits on one screen in a normal editor. It's also the threshold where a reviewer will read it end-to-end instead of skipping to the tests. Hitting it means you can paste the result into a Slack message and someone can learn the library from that message alone. That's the bar.
The code below is gofmt'd, all imports expanded, all error paths honored. No clever tricks, no helper package hidden elsewhere. What you see is what compiles.
The 50 lines
package main
import (
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := gpdf.NewDocument(template.WithPageSize(document.A4))
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("ACME Corp", template.FontSize(22), template.Bold())
c.Text("123 Business St, San Francisco")
})
r.Col(6, func(c *template.ColBuilder) {
c.Text("INVOICE #INV-2026-001", template.Bold(), template.AlignRight())
c.Text("Due: 2026-03-31", template.AlignRight())
})
})
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Spacer(document.Mm(6))
c.Table(
[]string{"Description", "Qty", "Unit Price", "Amount"},
[][]string{
{"Frontend dev", "40 hrs", "$150.00", "$6,000.00"},
{"Backend dev", "60 hrs", "$150.00", "$9,000.00"},
{"UI/UX design", "20 hrs", "$120.00", "$2,400.00"},
},
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
template.TableStripe(pdf.RGBHex(0xFAFAFA)),
)
c.Text("Total: $17,400.00", template.AlignRight(), template.Bold(), template.FontSize(14))
})
})
b, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("invoice.pdf", b, 0644); err != nil {
log.Fatal(err)
}
}
go run . produces invoice.pdf in the current directory. On an M1 the whole program finishes in a few milliseconds — the actual PDF generation is under 150 µs, the rest is process startup.
What each block is doing
Imports
Four packages from gpdf:
github.com/gpdf-dev/gpdf— the facade. The only thing we use from it isgpdf.NewDocument, which is a thin wrapper overtemplate.Newwith sensible defaults.github.com/gpdf-dev/gpdf/document— units (Mm,Pt,Cm,In,Em,Pct), page sizes (A4,Letter,Legal), margins.github.com/gpdf-dev/gpdf/pdf— color primitives (RGBHex,Gray, named constants likepdf.White).github.com/gpdf-dev/gpdf/template— the builder API. Everything that starts withtemplate.(options, layout functions, style modifiers) comes from here.
If the four-package split feels like a lot, that's by design. The pdf package is the low-level writer — you almost never touch it directly — but it exposes color types because colors are shared between text and tables and lines, and shoving them into template would make that package enormous. The other three you'll import in every file.
No external dependencies. go.mod after go get github.com/gpdf-dev/gpdf:
require github.com/gpdf-dev/gpdf v1.x.x
That's the whole require block. No indirect explosion.
Document construction
doc := gpdf.NewDocument(template.WithPageSize(document.A4))
gpdf.NewDocument takes a variadic ...template.Option. All configuration — page size, margins, default font, metadata, custom fonts — is a WithXxx option. If you want US Letter, swap document.A4 for document.Letter. Default margins are 20 mm; override with template.WithMargins(document.UniformEdges(document.Mm(15))) if you want tighter.
The invoice above ships with whatever the library's default font is (Helvetica equivalent, built-in, no TTF required). For Japanese, Chinese, or Korean invoices you register a TTF with template.WithFont — that's a different post and it's the main reason people come to gpdf, but for a dollar-USD invoice you don't need it.
The header row
page.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) { ... })
r.Col(6, func(c *template.ColBuilder) { ... })
})
This is the part that surprises people coming from gofpdf or gopdf: gpdf uses a 12-column grid, same mental model as Bootstrap. A row has 12 units of horizontal space. r.Col(6, ...) takes half. Two Col(6) calls add up to 12, which fills the row exactly.
AutoRow means the row's height is whatever its tallest column needs. If you want a fixed-height row — useful for card layouts — there's FixedRow(height, fn). For an invoice, auto is what you want.
Inside each column, you stack c.Text(...) calls top to bottom. No explicit positioning. The builder tracks a cursor internally and advances it by the rendered height of each element.
The right-side column uses template.AlignRight(). Text options are composable — c.Text("INVOICE", template.Bold(), template.AlignRight(), template.FontSize(20)) just layers three modifiers on one call. The order doesn't matter.
The items table
c.Table(
[]string{"Description", "Qty", "Unit Price", "Amount"},
[][]string{
{"Frontend dev", "40 hrs", "$150.00", "$6,000.00"},
...
},
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
template.TableStripe(pdf.RGBHex(0xFAFAFA)),
)
Three arguments, three shape-defining options. Nothing more.
ColumnWidths(40, 15, 20, 25) is percentages of the containing column, not absolute points. The four widths sum to 100. If you hand it 40, 20, 20, 20 (sum 100), that works; if you hand it 40, 15, 20, 30 (sum 105), the table still renders but the last column overflows — that's the one gotcha. Sum-to-100 or you get ugly output. We considered failing loudly on sum mismatch and decided against it; some layouts genuinely want a 90% total with 10% whitespace on the right, and forcing a check would break that.
TableHeaderStyle takes the same text options everything else takes (Bold, TextColor, BgColor, AlignCenter). If you want an opinionated dark-on-light header, this is one line. TableStripe(color) alternates the row background — the zebra effect. If you don't want stripes, omit it; rows render with transparent backgrounds.
What you don't do here:
- You don't specify row heights. The table measures each cell and picks the row height from the tallest.
- You don't specify fonts. The table inherits from the column's default, which inherits from the document.
- You don't handle pagination. If the table overflows the page, gpdf breaks it across pages and re-draws the header on each continuation page. The 50-line version above has three rows and clearly fits; if you push it to 100 rows, pagination is automatic.
The total
c.Text("Total: $17,400.00", template.AlignRight(), template.Bold(), template.FontSize(14))
Nothing clever. It's another Text call outside the table, right-aligned, slightly bigger. The visual distance from the table comes from the row's natural cursor advance — there's no explicit spacer above. If you want breathing room, add c.Spacer(document.Mm(3)) between c.Table(...) and c.Text(...).
Generate and write
b, err := doc.Generate()
if err != nil { log.Fatal(err) }
if err := os.WriteFile("invoice.pdf", b, 0644); err != nil { log.Fatal(err) }
doc.Generate() returns ([]byte, error). It doesn't touch the filesystem. The byte slice is a complete PDF — you can write it to disk, upload it to S3, stream it as an HTTP response with w.Write(b), or embed it in an email attachment. No temp files, no cleanup.
If you want streaming instead of a buffered slice, doc.Render(w io.Writer) exists and writes directly. For an invoice, which is kilobytes, the difference is noise. For a 10,000-page report you'd use Render.
Making it prettier (without breaking 50 lines)
The version above is functional but plain. A few single-line additions change the look significantly.
Brand color. Pick a hex (navy blue at 0x1A237E, a teal at 0x00796B, whatever) and thread it through the two places readers notice most — the company name and the table header:
brand := pdf.RGBHex(0x1A237E)
c.Text("ACME Corp", template.FontSize(22), template.Bold(), template.TextColor(brand))
// ...
template.TableHeaderStyle(template.Bold(), template.TextColor(pdf.White), template.BgColor(brand)),
Two new lines, one changed line. Still under 50.
Tax and subtotal. If the line above the total needs to break out subtotal and tax, stack three c.Text calls:
c.Text("Subtotal: $17,400.00", template.AlignRight())
c.Text("Tax (10%): $1,740.00", template.AlignRight())
c.Text("Total: $19,140.00", template.AlignRight(), template.Bold(), template.FontSize(14))
You've just blown the 50-line budget by two lines. Whether that's a real cost depends on whether you're selling the number "50" or the ability to add a tax line without rewriting the layout. We'd pick the tax line.
A rule above the total. Between the subtotal block and the total, drop a c.Line():
c.Spacer(document.Mm(2))
c.Line(template.LineThickness(document.Pt(0.5)))
c.Spacer(document.Mm(2))
A payment-info column under the header. Duplicate the header row structure with "Bill To" and "Payment Info" inside two new Col(6) cells. This is the only pattern that starts pushing the code toward a function extraction — two near-identical column layouts is fine, three is the point where you pull out func billTo(c *template.ColBuilder, ...).
Running it
mkdir invoice-demo
cd invoice-demo
go mod init example.com/invoice-demo
go get github.com/gpdf-dev/gpdf
# paste main.go
go run .
open invoice.pdf # macOS; xdg-open on Linux, start on Windows
The first go get pulls about 3 MB of source (no compiled binaries). Subsequent runs are instant because the module is cached.
If go run . produces nothing and exits cleanly, check whether the file was written somewhere unexpected — the program uses the current working directory as the output path. On a Docker build with a read-only filesystem, swap os.WriteFile for io.Copy(w, bytes.NewReader(b)) into an HTTP response.
Where this pattern breaks
The 50-line version scales up gracefully until one of four things happens.
The items list becomes data. If the line items come from a database query or a JSON payload instead of a hardcoded slice, the table stays identical — you just construct [][]string from your data. That's not a break; it's the expected shape.
You want to reuse the layout. The moment you're generating invoices in a loop, stop inlining the body into main. Extract func renderInvoice(doc *template.Document, inv Invoice). The 50-line template stays recognizable; you just pass the doc and the data through.
The layout branches. Some invoices have a purchase-order column, some don't. Some customers get a tax line, some don't. Once you have conditional sections, the Builder API starts feeling verbose and the JSON schema entrypoint (gpdf.NewDocumentFromJSON) or Go templates entrypoint becomes a better fit — you declare the structure in a template file and feed it data.
You need CJK text. Japanese, Chinese, or Korean characters in the above code render as tofu boxes because the default font is Latin-only. You need one extra call at document construction to register a TTF. That's covered in Why does my PDF show tofu boxes for Japanese? and How do I embed a Japanese font in gpdf?.
None of these are "rewrite from scratch" moments. They're incremental. The 50-line version is the starting shape you carry forward.
FAQ
Can I use this for commercial invoices without attribution? Yes. gpdf is MIT-licensed. Build whatever you want on top, including closed-source commercial products. Attribution isn't required, though a GitHub star is nice.
Does it support writing to io.Writer directly, without the byte slice?
Yes — doc.Render(w io.Writer) error. The doc.Generate() version above is a convenience for the common case where you want []byte to attach to something else.
How fast is this actually? The 50-line program above generates its PDF in about 100 µs on an M1, dominated by the three-row table and the text layout. A single-page hello-world lands at 13 µs. For batch workloads — nightly statement runs, bulk invoicing — gpdf's per-document cost is low enough that the bottleneck becomes whatever is feeding it data.
Can I generate an invoice PDF in Go without gpdf?
Sure. jung-kurt/gofpdf works (it's archived but stable), signintech/gopdf works at a lower level, and johnfercher/maroto gives you a different layout abstraction. All of them end up more verbose than the 50 lines above for the same invoice. We wrote more about that in the 2026 showdown.
Why isn't there a first-party gpdf.Invoice helper?
Because "invoice" means different things in different countries and every simplification picks a side. We'd rather give you a 50-line starting point you can adapt than a NewInvoice(companyName, lineItems) constructor that breaks the moment you need a Japanese 適格請求書 with a 登録番号 or a Brazilian NFe with DANFE metadata. The Builder API is the helper.
Does the PDF validate against PDF/A or any archival standard?
The default output is standard PDF 1.7. For PDF/A-2b (archival compliance) you add gpdf.WithPDFA(pdfa.Level2B) at document construction. That's a separate topic, covered in Building PDF/A-2b in pure Go.
Try gpdf
gpdf is a Go library for generating PDFs. MIT, zero dependencies, native CJK, 10–30× faster than alternatives on the workloads we benchmark.
go get github.com/gpdf-dev/gpdf
⭐ Star on GitHub · Read the docs
Next reads
- Why gpdf is 10–30× faster than other Go PDF libraries — the benchmark numbers behind the "few hundred microseconds" claim.
- Go PDF Library Showdown 2026 — how this 50 lines compares to the same invoice in gofpdf, gopdf, and Maroto.
- How does the 12-column grid work in gpdf? — the layout model used in the header row above, in more depth.