DEV Community

Cover image for What a Hexagonal Port Costs in Go: itabs, Escape Analysis and a Postgres Round Trip
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

What a Hexagonal Port Costs in Go: itabs, Escape Analysis and a Postgres Round Trip


You put a UserRepository interface between your domain and Postgres. The review comes back with one line: "Interfaces are slow in Go. Just pass the pool."

That objection is half right, and the half that is right is not the one people argue about. The dispatch itself is a pointer load and an indirect call. What costs you is that the compiler cannot see through the interface, so it stops inlining and starts sending values to the heap. That second part is a known, open compiler limitation: golang/go#36964 has tracked it since February 2020.

You can check all of this yourself with one benchmark file and -gcflags=-m.

What your domain holds: two words and an itab

An interface with methods is two machine words. The current layout lives in internal/abi/iface.go:

type NonEmptyInterface struct {
    ITab *ITab
    Data unsafe.Pointer
}

type ITab struct {
    Inter *InterfaceType
    Type  *Type
    Hash  uint32     // copy of Type.Hash
    Fun   [1]uintptr // variable sized
}
Enter fullscreen mode Exit fullscreen mode

Data points at your adapter, say a *pgRepo. ITab points at a table pairing the interface type with the concrete type, followed by one function pointer per method. The source comment says itabs are allocated in non-garbage-collected memory, and runtime/iface.go keeps them in a global table it reads without locks on the common path. They are built once, not per call.

The call repo.Save(ctx, u) then does what Russ Cox described back in Go Data Structures: Interfaces (2009): the equivalent of tab->fun[0](data). Load the itab pointer, load the function pointer, call it with the data word as the receiver.

Two connected blocks representing an interface value, one pointing to a table of method slots, the other to a concrete adapter
An interface value is two words: an itab pointer and a data pointer.

Vicent Martí spelled out what that indirection costs in PlanetScale's 2022 generics deep dive: a call through an interface prevents inlining. Lose inlining and you also lose the optimizations it would have opened up. (Devirtualization, below, is the exception.)

Where it costs you: escape analysis gives up at the interface

Escape analysis decides whether a value can live on the stack. To decide, it has to see every function the pointer is handed to. Through an interface, it can't: the callee is whatever sits in the itab at runtime.

In #36964, a Go compiler contributor explains the bind: at the moment you allocate the value, the compiler may not know which interface methods it will be passed to. So it assumes the worst. A pointer argument to an interface method escapes, even when every implementation you ship only reads it.

The receiver side has its own open issue. #62653, "avoid always escaping the receivers in interface method calls", is milestoned for Go 1.28 after moving from 1.27 in June 2026. A March 2026 report, #78336, shows a non-pointer value stored in an interface being heap-allocated through runtime.convT64 because a method is called on it.

That is the second allocation source to know about. Since Go 1.4, interface values always hold a pointer, so storing a non-pointer value in an interface means boxing it. The runtime skips the allocation for integers under 256 (staticuint64s) and for empty strings, and allocates otherwise. A logging decorator that passes u.ID into a ...any argument pays that on every call once your IDs pass 255.

A benchmark you can run: direct, port, decorated port

Run go mod init portcost in an empty directory and save what follows as port_test.go. One file, no dependencies. The first part is the domain type, the port, an in-memory adapter and a decorator:

package portcost

import (
    "context"
    "sync/atomic"
    "testing"
)

type User struct {
    ID    int64
    Name  string
    Email string
}

type UserRepository interface {
    Save(ctx context.Context, u *User) error
}

type memRepo struct{ last int64 }

func (r *memRepo) Save(ctx context.Context, u *User) error {
    r.last = u.ID
    return nil
}

// A decorator, the way metrics or logging wraps a port.
type counting struct {
    next  UserRepository
    calls atomic.Int64
}

func (r *counting) Save(ctx context.Context, u *User) error {
    r.calls.Add(1)
    return r.next.Save(ctx, u)
}
Enter fullscreen mode Exit fullscreen mode

Below it, in the same file, go two services that are identical except for the field type, and a benchmark for each setup. The //go:noinline on both Rename methods is deliberate: it keeps the outer call identical, so the only difference is what happens inside.

type Direct struct{ repo *memRepo }

//go:noinline
func (s *Direct) Rename(ctx context.Context, id int64) error {
    u := User{ID: id, Name: "renamed"}
    return s.repo.Save(ctx, &u)
}

type Ported struct{ repo UserRepository }

//go:noinline
func (s *Ported) Rename(ctx context.Context, id int64) error {
    u := User{ID: id, Name: "renamed"}
    return s.repo.Save(ctx, &u)
}

var bg = context.Background()

func BenchmarkDirect(b *testing.B) {
    s := &Direct{repo: &memRepo{}}
    for b.Loop() {
        _ = s.Rename(bg, 42)
    }
}

func BenchmarkPort(b *testing.B) {
    s := &Ported{repo: &memRepo{}}
    for b.Loop() {
        _ = s.Rename(bg, 42)
    }
}

func BenchmarkPortDecorated(b *testing.B) {
    s := &Ported{repo: &counting{next: &memRepo{}}}
    for b.Loop() {
        _ = s.Rename(bg, 42)
    }
}
Enter fullscreen mode Exit fullscreen mode

b.Loop needs Go 1.24 or newer. Before Go 1.26 it also blocked inlining in the loop body, which is one more reason for the //go:noinline pins: the outer call stays the same on every version. Run the benchmarks with allocation counts, and ask the compiler what it decided:

go test -bench . -benchmem -count 5
go test -run '^$' -bench . -gcflags=-m 2>&1 \
  | grep -E 'heap|devirt|inlin'
Enter fullscreen mode Exit fullscreen mode

I haven't run this myself, so there are no numbers here; yours are the ones that count anyway. In the -m output, Direct.Rename should show inlining call to (*memRepo).Save and no heap line for u. Ported.Rename is where you'd expect moved to heap: u, because &u goes into an interface method the compiler can't see. Those message formats come straight from the compiler's inliner, escape and devirtualize passes.

In the benchmark output, compare the allocs/op column before you look at ns/op. The decorated version adds a second indirect call plus an atomic add, which is about what a metrics decorator costs in production.

If -m prints nothing, the build came from cache. Touch the file and run it again.

When the compiler deletes the port for you

The devirtualize package runs two passes. The static one rewrites an interface call into a direct call when the concrete type is known at compile time, and -m reports it as devirtualizing ... to .... This is the classic benchmark trap: write var r UserRepository = &memRepo{} and call r.Save in the same function, and you are timing a direct call while thinking you're timing a port. If a devirtualizing line points at the line inside Ported.Rename, your port benchmark isn't measuring a port.

The second pass is profile-guided. Go 1.21 made PGO ready for general use and added devirtualization of interface calls to the most common callee. Go 1.22 widened it and interleaved devirtualization with inlining. The rewrite is a guarded direct call with the original call as fallback, shaped like this in the compiler's own doc comment:

if c, ok := i.(Concrete); ok {
    c.Foo()
} else {
    i.Foo()
}
Enter fullscreen mode Exit fullscreen mode

Two details from the source matter for ports. The pass skips callees it knows won't inline, because inlining is the whole point. And -pgo=auto only picks up default.pgo for main packages, per the go command docs, so in a test you pass the profile yourself:

go test -run '^$' -bench PortDecorated -cpuprofile cpu.pprof
go test -run '^$' -bench . -benchmem -pgo=cpu.pprof \
  -gcflags=-m 2>&1 | grep -i 'pgo devirtualizing'
Enter fullscreen mode Exit fullscreen mode

Notice that the fallback branch is still an ordinary interface call. My reading is that &u still flows into an unknown method there, so check whether moved to heap: u survives in the PGO build before you count on the allocation going away. For whole programs, the PGO guide reports 2 to 14% runtime gains across a representative set of Go programs as of Go 1.22.

Put it next to one Postgres round trip

Add a second file, pg_test.go, in the same directory. It uses pgx through database/sql (run go get github.com/jackc/pgx/v5 first) and skips itself unless you point it at a database.

package portcost

import (
    "database/sql"
    "os"
    "testing"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func BenchmarkPostgresRoundTrip(b *testing.B) {
    dsn := os.Getenv("PG_DSN")
    if dsn == "" {
        b.Skip("set PG_DSN to run")
    }
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        b.Fatal(err)
    }
    defer db.Close()
    if err := db.PingContext(bg); err != nil {
        b.Fatal(err)
    }
    var one int
    for b.Loop() {
        row := db.QueryRowContext(bg, "SELECT 1")
        if err := row.Scan(&one); err != nil {
            b.Fatal(err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it against the database your service actually talks to, not a laptop container, and divide. For a rough anchor before you have your own numbers, the latency table attributed to Jeff Dean puts a round trip within one datacenter at 500,000 ns and a branch mispredict at 5 ns. The table is old and your network is not Google's, but the ratio is the point: even if every port call cost as much as a mispredicted branch, one query would pay for about 100,000 of them, before the server does any work.

A balance scale with a tiny pebble on one side and a massive boulder on the other
Dispatch and one database round trip are not on the same scale.

The allocation is the part worth watching. One small heap object per call is cheap on its own, and Go 1.27 made some allocations under 80 bytes up to 30% cheaper. It adds up when a port sits inside a loop that runs per row or per item: a PriceRule interface called 100,000 times per request is a design problem. A UserRepository called twice per request, each time followed by a network trip, is not.

So keep ports where hexagonal architecture puts them anyway, at the I/O boundary. Pass batches across them, not single items in a tight loop. And when a reviewer says interfaces are slow, send them the -benchmem output and the SELECT 1 number side by side.


If this was useful

The benchmark tells you what a port costs. Where it belongs is the question Hexagonal Architecture in Go is built around: which interfaces the domain owns, how persistence adapters plug in, and how to lay out the packages so the boundary stays at the I/O edge. If you want the architecture side of what you just measured, start with the book here. It's half of the Thinking in Go series.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)