Deep Engineering
Intermediate·Published·30 MIN

Struct embedding in Go: composition that gets mistaken for inheritance

Interview questions on embedding climb a ladder: does Go have inheritance — what happens if the outer type declares the same method — why a struct with an embedded type does not implement an interface — what to do with two identical names — why embed an interface. A run answers each step: a promoted method calls its own type's method, not the outer one; two same-named fields silently vanish from JSON; and in a measurement the promoted call is indistinguishable from a direct one.

Full technical treatment

TL;DR

Embedding is a field declared without a separate name whose fields and methods are promoted and reachable as if they were declared on the outer type. The field does have a name — the type's name — and s.Log() resolves to the method of the embedded field s.Logger, nothing more. It is composition with promotion of fields and methods, not inheritance, and the difference shows on the very first question.

A promoted method receives the embedded value, not the outer one. Derived embeds Base and declares its own Name. d.Name() returns "Derived", while d.Describe() — a method of Base that calls Name — returns "describe: Base". Embedding has no virtual call: inside Describe the receiver is a Base, and its Name is called, whatever the outer type declared.

Beyond that comes what separates real understanding from having read about it. An embedded T promotes methods with receiver *T only into the method set of *S, so ByValue{} does not implement the interface while &ByValue{} does. Two identical names at the shallowest depth are not a declaration error: the compiler objects when the name is referenced, and neither of them becomes promoted — so the interface method is not satisfied by the type. An embedded interface left nil compiles and panics when a method the outer type did not declare is called. An embedded sync.Mutex makes Lock, TryLock and Unlock public methods of *Cache. Two embedded structs with an ID field produce JSON with no ID at all — and no error. And in a measurement on go1.24.7 the promoted call is indistinguishable from the direct one within noise: 2.14 ns against 2.18.

Where to start
Before this lesson it is enough to understand
  • how to declare a struct and a method with a receiver;
  • how a value receiver differs from a pointer receiver — that is the lesson on method receivers;
  • that a type implements an interface if it has all its methods.
You do not need to know in advance
  • promotion of fields and methods, the depth rule, the ambiguous selector;
  • the method set with an embedded T and *T, an embedded interface, an embedded sync.Mutex;
  • what embedding looks like in encoding/json.

What is really being asked

The ladder is almost always this one:

  1. "Does Go have inheritance?" — the warm-up; "no, there is embedding" is correct but explains nothing.
  2. "What happens if the outer type declares the same method as the embedded one?" — this checks whether the candidate understands there is no overriding, only shadowing.
  3. "Why does a struct with an embedded type not implement the interface?" — about method sets, and it is the same question as in the lesson on receivers.
  4. "What happens if you embed two types with the same method?" — the depth rule.
  5. "Why embed an interface in a struct?" — and what makes it dangerous.
  6. "Should you embed sync.Mutex?" — about embedding publishing someone else's methods.

The ladder has one spine: embedding decides which path to a field is substituted into an expression — and only that. Everything that behaves "not like inheritance" follows from that sentence.

Base: a field without a separate name

The specification defines embedding through a name that is not written:

A field declared with a type but no explicit field name is called an embedded field.

— The Go Specification — Struct types
language contractThe rules of promotion, method sets and depth are part of the language specification; the run of bench/goembed/internals.go illustrates them rather than establishes them.

The field does have a name after all — the specification adds at once that the unqualified type name acts as the field name. Logger is embedded in Server, and it can be reached as s.Logger. What is new is something else: the fields and methods of Logger are promoted — they become reachable directly, without the intermediate name:

GO
type Logger struct{ prefix string }
 
func (l Logger) Log(msg string) string { return l.prefix + msg }
 
type Server struct {
    Logger
    name string
}
1) PROMOTION OF FIELDS AND METHODS
──────────────────────────────────
  s.Log("start")           -> "[api] start"
  s.Logger.Log("start")    -> "[api] start"
  s.prefix                 -> "[api] "

s.Log is a promoted selector: the compiler resolves it to the method of the embedded field s.Logger, with the same result as the explicit s.Logger.Log. The specification calls such fields and methods promoted.

That is enough for a junior-level answer to "does Go have inheritance": no. There is embedding — a field without a separate name whose fields and methods are promoted and called directly. It is composition: Server contains a Logger, it is not one. A precision worth adding: a named field log Logger is composition too; embedding differs from it only by promotion.

One table is worth memorising right away — it answers whether the outer type implements an interface; why it looks like this is covered in Mechanism 2:

what is embeddedmethod declared with receiverends up in the method set of
TTS and *S
T*Tonly *S
*TT or *TS and *S

The last words are worth saying out loud, because the whole next step rests on them. You cannot pass a Server where a Logger is expected — they are different types; you pass s.Logger.

Mechanism 1: embedding has no virtual call

This is where the inheritance analogy breaks down. Base declares Name and Describe, and Describe calls Name. Derived embeds Base and declares its own Name:

GO
type Base struct{}
 
func (Base) Name() string { return "Base" }
 
func (b Base) Describe() string { return "describe: " + b.Name() }
 
type Derived struct{ Base }
 
func (Derived) Name() string { return "Derived" }

In a language with virtual methods — Java, or C++ with virtual — d.Describe() would return "describe: Derived": the parent's method would call the child's overriding method. In Go:

2) EMBEDDING HAS NO VIRTUAL CALL
────────────────────────────────
  d.Name()                 -> "Derived"
  d.Describe()             -> "describe: Base"
  d.Base.Name()            -> "Base"
  Describe is promoted from Base and receives Base, not Derived:
  the Name inside it is Base.Name, whatever the outer type declares.

The explanation is in that same sentence about the path to a field. d.Describe() is d.Base.Describe(). The receiver of Describe is a value of type Base, and the method does not know it lives inside a Derived. b.Name() inside it is a call on Base, chosen at compile time.

The outer Name is not useless: it hides the embedded one from everyone who calls d.Name() from outside. That is name shadowing, not overriding: it changes which path is chosen in an expression, but not what the embedded type's code does.

If what you need is exactly what inheritance gives — a shared algorithm calling a variable part — in Go that is done with an interface passed explicitly:

GO
type Namer interface{ Name() string }
 
func DescribeAny(n Namer) string { return "describe: " + n.Name() }
8) DYNAMIC CALL — THROUGH AN INTERFACE
──────────────────────────────────────
  DescribeAny(d)           -> "describe: Derived"

Go does have dynamic dispatch, but it lives in interfaces, not in embedding.

Mechanism 2: embedded T or *T — different method sets

Not every method is promoted, and not always. The rule in the specification is short:

If S contains an embedded field T, the method sets of S and *S both include promoted methods with receiver T. The method set of *S also includes promoted methods with receiver *T.

— The Go Specification — Struct types

For an embedded pointer the rule is more generous:

If S contains an embedded field *T, the method sets of S and *S both include promoted methods with receiver T or *T.

— The Go Specification — Struct types

A check on an interface whose method is declared on *Store:

3) METHOD SET: T OR *T EMBEDDED
───────────────────────────────
  ByValue{} (Store embedded)    implements Saver: false
  &ByValue{}                    implements Saver: true
  ByPointer{} (*Store embedded) implements Saver: true
  &ByPointer{}                  implements Saver: true

This is the same mechanism as in the lesson on method receivers, just one step further. A ByValue{} value put into an interface is a copy with no address; the Store embedded in it has no address either, and there is nothing to call a pointer-receiver method on. With an embedded *Store the pointer sits in the field itself, and Save always has an address.

When you try to put such a value into an interface, the compiler answers:

ptrrecv:
  ./main.go:14:16: cannot use ByValue{} (value of struct type ByValue) as Saver value in variable declaration: ByValue does not implement Saver (method Save has pointer receiver)

The practical consequence: on a struct with an embedded field this message is fixed the same way as without one — pass a pointer to the outer struct. Embedding *T for this is also a way out, but you pay for it with a field that can turn out to be nil.

Mechanism 3: depth — and when identical names become an error

Embedded types can embed their own, and every name acquires a depth — how many embedded fields are traversed to reach it. One rule decides the path:

x.f denotes the field or method at the shallowest depth in T where there is such an f. If there is not exactly one f with shallowest depth, the selector expression is illegal.

— The Go Specification — Selectors

The first half gives shadowing. An outer field at depth 0 hides the embedded one at depth 1:

4) DEPTH: THE OUTER NAME HIDES THE EMBEDDED ONE
───────────────────────────────────────────────
  o.ID                     -> "outer"
  o.Inner.ID               -> "inner"

The second gives ambiguity. File embeds Reader and Writer, both have Close. Declaring such a type is allowed; referencing the name is an error:

With the reference f.Close():
  # deepengineering/bench/goembed/ambiguous
  ./main.go:24:8: ambiguous selector f.Close

Without it (line 24 commented out):
  builds without errors

But the ambiguity is not harmless even without a reference. At an equal shallowest depth none of the candidates becomes a promoted Close — so the interface's Close method is not satisfied by File. Assigning it to an interface does not compile, even though there is no f.Close in the file, and a run-time type assertion quietly returns false:

ambigiface:
  ./main.go:22:17: cannot use File{} (value of struct type File) as Closer value in variable declaration: File does not implement Closer (ambiguous selector File.Close)
10) AMBIGUOUS NAME AND THE METHOD SET
─────────────────────────────────────
  any(File{}).(Closer)     -> implements: false

The rule concerns only the shallowest depth. The same name at different depths is not an error, while identical names at depth 0 are already a declaration error (ID redeclared): those are two fields of one struct, not promoted names.

This matters beyond the exam. It means ambiguity can appear in code you did not touch: if, in a new version of a library, one of the embedded types gains a method with the same name as the other, your call stops compiling — and a type assertion somewhere in another package quietly starts returning false. The explicit path — f.Reader.Close() — works in both cases.

Mechanism 4: an embedded interface — an implementation that panics on call

You can embed not only a struct but an interface. The outer type then gets all of the interface's methods — and the compiler considers the interface implemented even if the field is left nil:

GO
type ReadCloser interface {
    Read() string
    Close() string
}
 
type fakeRC struct {
    ReadCloser
}
 
func (fakeRC) Read() string { return "data" }
5) EMBEDDED INTERFACE LEFT nil
──────────────────────────────
  rc.Read()                -> "data"
  rc.Close()               -> panic: runtime error: invalid memory address or nil pointer dereference

Read is declared on fakeRC and works. Close is promoted from the ReadCloser field, the field is nil, and the call panics. The compiler never objected.

The technique has two uses, both legitimate. A test stub: the interface has ten methods and the test needs one — embed the interface and declare that one. If the code under test calls another, the test fails loudly instead of passing silently. A wrapper: embed the real object (for example, http.ResponseWriter) and declare your own version of one method while the rest pass through. It is dangerous for exactly what the run shows: the promise "implements the interface" is checked at compile time and kept only at call time.

Mechanism 5: what embedding exposes

Promotion works for everyone — including the people who use your type. Hence embedding's main production risk: an embedded type from another package keeps changing without you. A new exported method in the next version of a dependency automatically becomes part of your type's API; if the same method appears on another embedded type, you get the ambiguity from Mechanism 3, and the type silently stops satisfying interfaces with that method. Not a single line of your code changes.

An embedded sync.Mutex — a common trick "so I can write c.Lock()":

GO
type Cache struct {
    sync.Mutex
    data map[string]string
}
6) SOMEONE ELSE'S METHODS IN THE PUBLIC API
───────────────────────────────────────────
  methods of *Cache: [Lock TryLock Unlock]

Cache declared none of the three methods, yet any code holding a *Cache can take and release its lock. The lock stops being an implementation detail. That is why a mutex is usually a named field — mu sync.Mutex: three more characters to type, and it is invisible from outside. And a struct with an embedded mutex must not be copied, just as with a named one — that is covered in the lesson on sync and atomic.

JSON. Here it is important not to mix two sets of rules: Go's selector rules and the field-selection rules of encoding/json are different things. The package looks at embedding its own way: it lifts the fields of an embedded struct to the top level of the object, takes into account tags that mean nothing to selectors, and resolves name conflicts with its own rule, always without an error — and in one case it drops every conflicting field:

Otherwise there are multiple fields, and all are ignored; no error occurs.

— The encoding/json package — Marshal
7) JSON: PROMOTION AND THE SILENT CONFLICT
──────────────────────────────────────────
  two embedded IDs          {"Title":"report"}
  one of them tagged        {"ID":2,"Title":"report"}

Audit and Owner both carry an ID field, and the JSON contains neither. No encoding error, no warning — the field simply vanished from the API response. A json:"ID" tag on one of the fields resolves the conflict in its favour — while for a selector the tag changes nothing:

tagsel:
  ./main.go:18:18: ambiguous selector DocTagged{}.ID

Deeper: the promoted call is indistinguishable from the direct one — the path is chosen at compile time

measured observationbench/goembed/practice.go, go1.24.7 linux/amd64, GOMAXPROCS=2, best of 7 rounds. What is stable is the ratio, not the nanoseconds.

Everything above comes down to one thing: the path to a field or method is chosen at compile time, by depth. For an embedded struct there is no search up a chain of "parents" at run time and no method table. It shows in the cost too:

  e.Rev()  direct call                    2.18 ns
  c.Rev()  promoted from embedded *Engine 2.14 ns
  ratio                                   0.98

In the measured concrete calls on go1.24.7 the promoted and direct paths are indistinguishable within noise: c.Rev() simply is c.Engine.Rev(), and there is no extra "inheritance" lookup at run time. This is a sanity check on one version, not a language guarantee in nanoseconds. One caveat: if an interface is embedded, a call through it is ordinary interface dispatch, as with any field of interface type.

Hence the summary table, handy to keep in mind instead of the word "inheritance":

what you expect from inheritancein embedding
the parent's methods and fields are available on the childyes — promotion
your own method hides the parent's when called from outsideyes — shadowing by depth
the parent's method calls the child's overriding methodno — embedding has no virtual call
the child can be passed where the parent is expectedno — different types; you pass the field
you pay for it at run timeno for an embedded struct — the path is decided at compile time

How to answer in an interview

Short answer: Go has no inheritance. It has embedding — a field without a name whose fields and methods can be called directly; s.Log() is shorthand for s.Logger.Log(). It is composition: the outer type contains the embedded one rather than being it, and a method of the embedded type works with its own value, not with the outer one.

That is enough for a correct answer. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

On "what if the outer type declares the same method", show Describe. "From outside, d.Name() returns the outer Name — it hides the embedded one. But the promoted Describe, which calls Name, receives a Base and calls Base.Name: in the run d.Describe() gave 'describe: Base'. Embedding has no virtual call; if you need one — an interface."

On "why doesn't it implement the interface", name the method-set rule. "An embedded T promotes methods with receiver *T only into the set of *S. So the interface is implemented by a pointer to the outer struct, not by the value."

On "two identical methods" — the depth rule. "At the shallowest depth it is an ambiguous selector error on reference; the declaration is legal, but neither candidate becomes promoted, and the type no longer satisfies the interface method. An outer name at a shallower depth hides the embedded one."

On "why embed an interface" — stubs and wrappers, and the price. "The compiler considers the interface implemented, and a method the outer type did not declare panics if the field is nil."

On sync.Mutex and JSON — what leaks outside. "An embedded mutex publishes Lock and Unlock; that is why it goes in a mu field. Same-named fields of two embedded structs both vanish from JSON, with no error."

Next they ask

Next they ask

How do you call the embedded method from the outer type's own method?

Short answer

By the explicit path: d.Base.Name(). An embedded field is an ordinary field named after its type, and it can always be reached. That is how wrappers are written: the outer method does its own work and calls w.ResponseWriter.WriteHeader(code) — the embedded one.

This is one more point where embedding differs from inheritance: there is no super, and none is needed, because the "parent" is just a field.

Next they ask

What happens if the embedded pointer turns out to be nil?

Short answer

It depends on what you access:

9) EMBEDDED POINTER LEFT nil
────────────────────────────
  h.Ptr()  (receiver *T)   -> "receiver is nil, the method checked and ran"
  h.Val()  (receiver T)    -> panic: runtime error: invalid memory address or nil pointer dereference
  h.X      (field)         -> panic: runtime error: invalid memory address or nil pointer dereference

A method with receiver *T receives nil and can check for it. A method with receiver T and a promoted field both need the pointer dereferenced — and that panics.

Hence the practical rule: an embedded *T is more convenient for method sets, but it adds state that an embedded T does not have — the field can be empty. A constructor that fills it becomes mandatory.

Next they ask

Is embedding an interface in an interface the same thing?

Short answer

No, and they are worth keeping apart. An interface embedded in an interface simply unites method sets: io.ReadWriter is Read and Write together. Embedding here adds no field that could be left nil; the interface value itself can be nil, like any other.

An interface embedded in a struct is a field of interface type. It holds a value, can be nil, and a call through it is an ordinary interface method call with all that implies.

Next they ask

Should you embed types from other packages?

Short answer

Carefully — this is the main production risk from Mechanism 5, for two reasons. The first is API: all exported methods of the embedded type become yours, and you cannot remove them later without breaking users. The second is ambiguity: if, in a new version of the library, the embedded type gains a method with the same name as another embedded type, referencing it stops compiling, and the type silently stops implementing interfaces with that method.

A named field has neither problem; the price is that the methods you need have to be declared by hand and forwarded.

Common misconceptions

Claim

Embedding is inheritance in Go

Actually

Embedding is a field without a separate name plus promotion of its fields and methods: a shorthand for the path to them. The outer type contains the embedded one rather than being it: you cannot pass a Derived where a Base is expected; you pass d.Base.

Claim

Your own method on the outer type overrides the embedded one

Actually

It hides it — for callers from outside. A promoted method of the embedded type still calls its own type's method: in the run d.Name() gave "Derived", and d.Describe() gave "describe: Base".

Claim

A struct with an embedded type implements every interface that type implements

Actually

Not always. An embedded T promotes methods with receiver *T only into the set of *S: ByValue does not implement Saver, while &ByValue does. With an embedded *T both do.

Claim

Two embedded types with the same method are a compile error

Actually

The declaration is legal; the reference f.Close() is an error: ambiguous selector f.Close. But neither candidate becomes a promoted Close, and the interface method is not satisfied by File: assigning it to an interface does not compile, and a type assertion returns false. The explicit path f.Reader.Close() works.

Claim

If a struct compiles as an implementation of an interface, all its methods work

Actually

Not if the interface is embedded and the field is left nil: in the run rc.Close() panicked with invalid memory address or nil pointer dereference. The compile-time check sees the method set, not whether there is a value behind the field.

Claim

On a field conflict encoding/json takes one of them or returns an error

Actually

Neither: two untagged ID fields at the same depth both vanish — {"Title":"report"}, and there is no error. That is exactly what the package documentation says.

Claim

A promoted call is more expensive than a direct one — it is looked up along a chain at run time

Actually

For an embedded struct the path is chosen at compile time. Measured: 2.14 ns against 2.18 for the direct call, a ratio of 0.98.

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.

Practice · predict the output

Base declares Name and Describe, and Describe calls Name; Derived embeds Base and declares its own Name. Save is declared on *Store, and ByValue embeds Store. Doc embeds Audit and Owner, both with an ID field. What will this code print?
type Base struct{}

func (Base) Name() string       { return "Base" }
func (b Base) Describe() string { return "describe: " + b.Name() }

type Derived struct{ Base }

func (Derived) Name() string { return "Derived" }

d := Derived{}
fmt.Println(d.Name())
fmt.Println(d.Describe())
_, ok := any(ByValue{}).(Saver)
fmt.Println(ok)
b, _ := json.Marshal(Doc{Audit{1}, Owner{2}, "report"})
fmt.Println(string(b))

Practice · estimate

The method is declared on *Engine; Car embeds *Engine. How many times more expensive is the call c.Rev() than the direct e.Rev()?
times

Knowledge check

Question 1 of 5

Derived embeds Base and declares its own Name. The method Base.Describe calls b.Name(). What does d.Describe() return?

Sources & further reading

3 SOURCES

  1. The Go Specification — Struct typesOfficial documentation. The definition and the promotion rule. What an embedded field is: «A field declared with a type but no explicit field name is called an embedded field»; and where its name comes from after all: «The unqualified type name acts as the field name». What gets promoted: «A field or method f of an embedded field in a struct x is called promoted if x.f is a legal selector that denotes that field or method f». And method sets: «If S contains an embedded field T, the method sets of S and *S both include promoted methods with receiver T. The method set of *S also includes promoted methods with receiver *T». And for an embedded pointer: «If S contains an embedded field *T, the method sets of S and *S both include promoted methods with receiver T or *T».https://go.dev/ref/spec#Struct_types
  2. The Go Specification — SelectorsOfficial documentation. The depth rule from which both shadowing and ambiguity follow: «x.f denotes the field or method at the shallowest depth in T where there is such an f. If there is not exactly one f with shallowest depth, the selector expression is illegal».https://go.dev/ref/spec#Selectors
  3. The encoding/json package — MarshalOfficial documentation. How embedded structs come out in JSON: «Embedded struct fields are usually marshaled as if their inner exported fields were fields in the outer struct». And the conflict rule the lesson checks with a run: «Otherwise there are multiple fields, and all are ignored; no error occurs».https://pkg.go.dev/encoding/json#Marshal