Verification (10 August 2026): Self-contained runnable examples were compile-tested with Go 1.26.5 on Windows/amd64. Multi-file or contextual snippets and intentional compiler-error demonstrations were checked separately in the repository verification matrix.
This tutorial explains what Go interfaces are, how implicit satisfaction works, what an interface value actually holds, and the typed-nil trap that follows from it. Every example compiles and its output is real. By the end you can design small interfaces and refactor a concrete dependency into a testable one.
Works with Go 1.18+ (all examples tested on Go 1.24). If you are new to Go, start with the complete Go tutorial first, since this article assumes you know structs and methods.
The problem interfaces solve: “anything that can X”
An interface describes behavior, not data. It says: I do not care what you are, I care what you can do.
Here is the pain without them. Your service alerts the on-call engineer, and you support email and Slack. With only concrete types, you write one function per type:
func alertByEmail(email EmailNotifier, incident string) error {
return email.Notify("incident: " + incident)
}
func alertBySlack(slack SlackNotifier, incident string) error {
return slack.Notify("incident: " + incident)
}
The bodies are identical. Add PagerDuty and SMS next quarter and you have four copies of the same logic, four call sites to update, four places for the formatting to drift. What you want is one function that accepts “anything that can send a notification”. That is exactly what an interface type is: a named set of method signatures, and any type with those methods qualifies.
Interfaces are Go’s mechanism for polymorphism and the backbone of the standard library: error, io.Reader, and http.Handler are all interfaces. You cannot read real Go code without them.
Go interfaces are satisfied implicitly, and that is deliberate
In Java or C# you write class EmailNotifier implements Notifier. Go has no implements keyword. The spec says a type implements an interface if its method set includes every method the interface lists. That is the whole rule. Satisfaction is structural and checked at compile time, with zero declarations.
Why did Go choose this? The Go FAQ gives the reason: a type can satisfy interfaces it has never heard of, including ones written years later in packages its author does not know exist. os.File satisfies io.Reader, but it also satisfies any one-method interface you define today. Nobody had to plan for that.
The practical consequence is the single most important design shift for developers coming from Java: in Go, the code that uses a dependency defines the interface, not the code that provides it. You will see why that matters in the refactoring section.
Defining and satisfying an interface: one Notifier, two implementations
Here is the alerting example done properly. One interface, two implementations, one function that works with both:
package main
import "fmt"
type Notifier interface {
Notify(message string) error
}
type EmailNotifier struct {
SMTPHost string
From string
}
func (e EmailNotifier) Notify(message string) error {
// production code would call smtp.SendMail here
fmt.Printf("email via %s: %s\n", e.SMTPHost, message)
return nil
}
type SlackNotifier struct {
WebhookURL string
Channel string
}
func (s SlackNotifier) Notify(message string) error {
// production code would POST JSON to the webhook here
fmt.Printf("slack #%s: %s\n", s.Channel, message)
return nil
}
func alertOnCall(n Notifier, incident string) error {
return n.Notify("incident: " + incident)
}
func main() {
email := EmailNotifier{SMTPHost: "smtp.internal:587", From: "[email protected]"}
slack := SlackNotifier{WebhookURL: "https://hooks.slack.com/T0/B0/x", Channel: "ops"}
if err := alertOnCall(email, "payment API returning 502s"); err != nil {
fmt.Println("alert failed:", err)
}
if err := alertOnCall(slack, "payment API returning 502s"); err != nil {
fmt.Println("alert failed:", err)
}
}
Output:
email via smtp.internal:587: incident: payment API returning 502s
slack #ops: incident: payment API returning 502s
Neither struct mentions Notifier. They have a Notify(string) error method, so they satisfy it. alertOnCall is written once and never changes when you add a fourth channel.
Note that Notify returns an error. Interfaces and error handling are tightly linked in Go: an interface method that can fail should say so in its signature, because the caller cannot see the implementation.
What an interface value holds under the hood: a type and a value
An interface value is a two-word pair: the dynamic type of whatever was stored in it, and the value itself. Assigning SlackNotifier{...} to a Notifier variable stores (type: SlackNotifier, value: the struct); the interface is nil only when both words are unset.
You can inspect both halves with fmt:
func main() {
var n Notifier
fmt.Printf("type=%T value=%v nil=%t\n", n, n, n == nil)
n = SlackNotifier{Channel: "ops"}
fmt.Printf("type=%T value=%v nil=%t\n", n, n, n == nil)
}
Output:
type=<nil> value=<nil> nil=true
type=main.SlackNotifier value={ops} nil=false
Keep this two-word model in your head. It explains method dispatch (the type word picks which Notify runs), why interface comparison can panic, and the trap in the next section, which catches nearly everyone once.
The classic trap: a typed nil inside a non-nil interface
Interview favorite, and one of the entries in our Go interview questions that trips up experienced candidates. Store a nil pointer in an interface and the interface is not nil, because the type word is set even though the value word is nil.
In real code it appears when a function returns a concrete error pointer instead of error:
package main
import "fmt"
type ConfigError struct {
Path string
}
func (e *ConfigError) Error() string {
return "invalid config: " + e.Path
}
// loadConfig returns *ConfigError instead of error. That is the bug.
func loadConfig(path string) *ConfigError {
// pretend the file validated fine
return nil
}
func main() {
var err error = loadConfig("app.yaml")
fmt.Println("err == nil:", err == nil)
fmt.Printf("type=%T value=%v\n", err, err)
if err != nil {
fmt.Println("refusing to start:", err)
}
}
Output:
err == nil: false
type=*main.ConfigError value=<nil>
refusing to start: <nil>
loadConfig returned nil, yet the program takes the error branch and logs the useless message refusing to start: <nil>. The assignment to error wrapped the nil pointer in an interface value of (type: *ConfigError, value: nil). A non-nil type word means a non-nil interface. The Go FAQ covers this exact case.
The fix is a rule you can apply mechanically: functions that can fail return the error interface type, never a concrete error pointer type.
func loadConfig(path string) error {
// return &ConfigError{...} only when there is a real error
return nil
}
With this signature the same return nil stores an untyped nil in the interface, both words unset, and rerunning the check prints err == nil: true.
The empty interface and any: when a function accepts everything
An interface with zero methods is satisfied by every type. Go 1.18 added any as an alias for interface{}, and you should write any in new code. You meet it in structured logging, JSON handling, and anywhere the shape of the data is not known at compile time:
func main() {
fields := map[string]any{
"user_id": 1042,
"path": "/checkout",
"duration": 183.4,
}
for key, value := range fields {
fmt.Printf("%s=%v (%T)\n", key, value, value)
}
}
Output (map iteration order is randomized in Go, so your order will differ):
user_id=1042 (int)
path=/checkout (string)
duration=183.4 (float64)
any says nothing about behavior, so the compiler can check nothing for you. Treat it as a boundary tool (unknown JSON, logging), not a convenience in your core types. When the real requirement is one function over several known types, generics keep compile-time checking and are usually the better choice.
Type assertions and type switches recover the concrete type
Once a value is behind an interface you can only call the interface’s methods. To get the concrete value back, assert:
func main() {
var payload any = "order-1042"
id := payload.(string) // works: payload holds a string
fmt.Println("id:", id)
count, ok := payload.(int) // comma-ok: no panic on mismatch
fmt.Println("count:", count, "ok:", ok)
}
Output:
id: order-1042
count: 0 ok: false
The single-result form panics when the type does not match, so use the comma-ok form whenever the input is not fully under your control (more on this under common mistakes).
When you need to handle several possible types, a type switch reads far better than chained assertions. This is the shape of real code inside logging and encoding libraries:
func formatField(value any) string {
switch v := value.(type) {
case nil:
return "null"
case string:
return fmt.Sprintf("%q", v)
case int, int64, float64:
return fmt.Sprintf("%v", v)
case []byte:
return fmt.Sprintf("%d bytes", len(v))
case error:
return "error: " + v.Error()
default:
return fmt.Sprintf("unhandled %T", v)
}
}
func main() {
fmt.Println(formatField("checkout"))
fmt.Println(formatField(1042))
fmt.Println(formatField([]byte{0x1f, 0x8b, 0x08}))
fmt.Println(formatField(nil))
fmt.Println(formatField(3 + 2i))
}
Output:
"checkout"
1042
3 bytes
null
unhandled complex128
Inside each case, v has that case’s concrete type, so len(v) works in the []byte branch and v.Error() works in the error branch. Always include a default; new types will reach this function eventually.
Small interfaces are the idiom: what io.Reader teaches
The standard library’s most-implemented interfaces have one or two methods. io.Reader is one method:
type Reader interface {
Read(p []byte) (n int, err error)
}
That single method abstracts files, network connections, HTTP bodies, gzip streams, strings, and in-memory buffers. Write your function against io.Reader and it works with all of them, including ones that do not exist yet:
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"log"
"os"
"strings"
)
// countLines works with any source of bytes: files, strings,
// network connections, gzip streams, HTTP bodies.
func countLines(r io.Reader) (int, error) {
scanner := bufio.NewScanner(r)
lines := 0
for scanner.Scan() {
lines++
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("scanning input: %w", err)
}
return lines, nil
}
func main() {
// a string
fromString, err := countLines(strings.NewReader("GET /\nGET /health\n"))
if err != nil {
log.Fatal(err)
}
fmt.Println("from string:", fromString)
// an in-memory buffer (what you use in tests)
var buf bytes.Buffer
buf.WriteString("POST /orders\nGET /orders/77\nDELETE /orders/77\n")
fromBuffer, err := countLines(&buf)
if err != nil {
log.Fatal(err)
}
fmt.Println("from buffer:", fromBuffer)
// a real file
if err := os.WriteFile("access.log", []byte("line 1\nline 2\nline 3\nline 4\n"), 0o644); err != nil {
log.Fatal(err)
}
logFile, err := os.Open("access.log")
if err != nil {
log.Fatal(err)
}
defer logFile.Close()
fromFile, err := countLines(logFile)
if err != nil {
log.Fatal(err)
}
fmt.Println("from file:", fromFile)
}
Output:
from string: 2
from buffer: 3
from file: 4
Notice what testing countLines costs: nothing. No temp files, no network. Hand it a bytes.Buffer and assert on the result. That is the payoff of a one-method parameter type, and it is why “the bigger the interface, the weaker the abstraction” is a Go proverb: every added method shrinks the set of satisfying types and enlarges every fake you write.
Accept interfaces, return structs, and when to break the rule
The guideline: function parameters should be interface types when you need flexibility, but return types should be concrete structs. Callers of countLines can pass anything readable, but a constructor like NewInvoiceService should return *InvoiceService, not some InvoiceServicer interface.
Why return concrete types? A struct return keeps every method and field available, and adding a method later breaks nobody. An interface return hides everything not in the interface, and widening it later breaks every other implementation. The standard library follows this: os.Open returns *os.File, and you narrow it to io.Reader at the point of use.
When to break it: return an interface when the concrete type is genuinely private or varies by construction. errors.New returns error because the underlying type is an unexported implementation detail. If you cannot name a reason like that, return the struct.
Real-world refactor: making a service testable with a consumer-side interface
Here is the situation this article has been building toward. You have a billing service hard-wired to an SMTP client:
type SMTPMailer struct {
host string
port int
}
func (m *SMTPMailer) Send(to, subject, body string) error {
// dials m.host, runs the SMTP handshake, sends the message
return nil
}
type InvoiceService struct {
mailer *SMTPMailer // concrete dependency, cannot be swapped in tests
}
func (s *InvoiceService) SendInvoice(customerEmail string, amountCents int) error {
body := fmt.Sprintf("Amount due: $%.2f", float64(amountCents)/100)
return s.mailer.Send(customerEmail, "Your invoice", body)
}
Every test of SendInvoice now needs a reachable SMTP server, or it does not run. The fix is a one-method interface, defined in the billing package, next to the code that uses it:
package billing
import "fmt"
// EmailSender is defined here, by the consumer. It lists only
// what InvoiceService needs, not everything SMTPMailer can do.
type EmailSender interface {
Send(to, subject, body string) error
}
type InvoiceService struct {
sender EmailSender
}
func NewInvoiceService(sender EmailSender) *InvoiceService {
return &InvoiceService{sender: sender}
}
func (s *InvoiceService) SendInvoice(customerEmail string, amountCents int) error {
body := fmt.Sprintf("Amount due: $%.2f", float64(amountCents)/100)
if err := s.sender.Send(customerEmail, "Your invoice", body); err != nil {
return fmt.Errorf("sending invoice to %s: %w", customerEmail, err)
}
return nil
}
*SMTPMailer already has the right Send method, so it satisfies EmailSender without changing a line, thanks to implicit satisfaction. Production code passes the real mailer. Tests pass a fake:
package billing
import (
"errors"
"testing"
)
// fakeSender records calls instead of talking to an SMTP server.
type fakeSender struct {
sentTo []string
failWith error
}
func (f *fakeSender) Send(to, subject, body string) error {
if f.failWith != nil {
return f.failWith
}
f.sentTo = append(f.sentTo, to)
return nil
}
func TestSendInvoiceEmailsCustomer(t *testing.T) {
fake := &fakeSender{}
svc := NewInvoiceService(fake)
if err := svc.SendInvoice("[email protected]", 4999); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fake.sentTo) != 1 || fake.sentTo[0] != "[email protected]" {
t.Errorf("want one email to [email protected], got %v", fake.sentTo)
}
}
func TestSendInvoiceWrapsSMTPError(t *testing.T) {
fake := &fakeSender{failWith: errors.New("smtp: connection refused")}
svc := NewInvoiceService(fake)
err := svc.SendInvoice("[email protected]", 4999)
if err == nil {
t.Fatal("want an error when sending fails")
}
t.Log(err)
}
Output of go test -v:
=== RUN TestSendInvoiceEmailsCustomer
--- PASS: TestSendInvoiceEmailsCustomer (0.00s)
=== RUN TestSendInvoiceWrapsSMTPError
billing_test.go:42: sending invoice to [email protected]: smtp: connection refused
--- PASS: TestSendInvoiceWrapsSMTPError (0.00s)
PASS
ok example.com/billing 0.002s
The fake is a dozen lines, exercises the failure path (which you could never trigger on demand against a real SMTP server), and runs in milliseconds. This exact refactor, applied to database clients, HTTP APIs, and message queues, is most of what “interfaces for testability” means in production Go.
Common mistakes with Go interfaces
Defining interfaces that are too big
// Too big: every fake must implement all six methods,
// and most callers use one or two of them.
type UserStore interface {
CreateUser(u User) error
GetUser(id int) (User, error)
UpdateUser(u User) error
DeleteUser(id int) error
ListUsers(page, size int) ([]User, error)
CountUsers() (int, error)
}
A function that only reads users should ask for only that:
type UserGetter interface {
GetUser(id int) (User, error)
}
The concrete store satisfies both automatically. Declare the narrow interface where it is consumed and let each function state its real requirements.
Defining interfaces on the producer side
A common habit imported from Java: the package that implements a client also exports a matching interface, “for mocking”. Now every consumer imports the producer’s abstraction, the interface grows to cover every consumer’s needs, and you are back to the six-method problem. Go’s convention is the opposite, made possible by implicit satisfaction: consumers define minimal interfaces locally, producers export concrete types. The EmailSender refactor above is the pattern.
Asserting without comma-ok
func main() {
var payload any = 1042
orderID := payload.(string) // payload holds an int, not a string
fmt.Println(orderID)
}
Output:
panic: interface conversion: interface {} is int, not string
goroutine 1 [running]:
main.main()
...
exit status 2
One malformed message in a queue and your worker is down. Use the two-result form, orderID, ok := payload.(string), whenever the value comes from JSON, a queue, a cache, or any other boundary. Reserve the panicking form for cases where a mismatch means the program itself is wrong.
Comparing interface values that hold uncomparable types
Interface values support ==, which compares the (type, value) pairs. But if the dynamic type is uncomparable (a slice, map, or function), the comparison panics at runtime, and the compiler cannot warn you:
func main() {
var previous any = []string{"admin", "billing"}
var current any = []string{"admin", "billing"}
fmt.Println(previous == current) // panics at runtime
}
Output:
panic: runtime error: comparing uncomparable type []string
goroutine 1 [running]:
main.main()
...
exit status 2
This bites wherever any values get compared: cache keys, deduplication, hand-rolled equality checks. If interface values can hold slices or maps, use reflect.DeepEqual or compare specific fields after a type assertion. Using such interface values as map keys panics identically.
What next
Interfaces show up everywhere in Go, so the next steps depend on where you are headed:
- The
errorinterface is Go’s most-used interface. Error handling, wrapping, and custom error types builds directly on what you learned here, including more on the typed-nil rule. - The typed-nil trap,
io.Readerdesign questions, and interface internals are standard interview material. Test yourself against 50+ Go interview questions with answers. - The fake-based testing pattern from the refactor section is expanded in the Go testing guide.
- Method sets are where interfaces meet the value vs pointer receiver decision; the Go type-system guide explains why a pointer receiver can change which interfaces a type satisfies.
- If you skipped ahead and structs or methods felt shaky, go back to the complete Go tutorial.