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.
- 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.
- promotion of fields and methods, the depth rule, the ambiguous selector;
- the method set with an embedded
Tand*T, an embedded interface, an embeddedsync.Mutex; - what embedding looks like in
encoding/json.
What is really being asked
The ladder is almost always this one:
- "Does Go have inheritance?" — the warm-up; "no, there is embedding" is correct but explains nothing.
- "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.
- "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.
- "What happens if you embed two types with the same method?" — the depth rule.
- "Why embed an interface in a struct?" — and what makes it dangerous.
- "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 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:
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 embedded | method declared with receiver | ends up in the method set of |
|---|---|---|
T | T | S and *S |
T | *T | only *S |
*T | T or *T | S 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:
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:
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.
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.
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 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:
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()":
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.
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
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 inheritance | in embedding |
|---|---|
| the parent's methods and fields are available on the child | yes — promotion |
| your own method hides the parent's when called from outside | yes — shadowing by depth |
| the parent's method calls the child's overriding method | no — embedding has no virtual call |
| the child can be passed where the parent is expected | no — different types; you pass the field |
| you pay for it at run time | no 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
How do you call the embedded method from the outer type's own method?
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.
What happens if the embedded pointer turns out to be nil?
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.
Is embedding an interface in an interface the same thing?
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.
Should you embed types from other packages?
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
Embedding is inheritance in Go
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.
Your own method on the outer type overrides the embedded one
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".
A struct with an embedded type implements every interface that type implements
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.
Two embedded types with the same method are a compile error
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.
If a struct compiles as an implementation of an interface, all its methods work
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.
On a field conflict encoding/json takes one of them or returns an error
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.
A promoted call is more expensive than a direct one — it is looked up along a chain at run time
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
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
Knowledge check
Derived embeds Base and declares its own Name. The method Base.Describe calls b.Name(). What does d.Describe() return?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- 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 fields.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.
DerivedembedsBaseand declares its ownName.d.Name()returns"Derived", whiled.Describe()— a method ofBasethat callsName— returns"describe: Base". Embedding has no virtual call: insideDescribethe receiver is aBase, and itsNameis called, whatever the outer type declared. - Beyond that comes what separates real understanding from having read about it. An embedded
Tpromotes methods with receiver*Tonly into the method set of*S, soByValue{}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 leftnilcompiles and panics when a method the outer type did not declare is called. An embeddedsync.MutexmakesLock,TryLockandUnlockpublic methods of*Cache. Two embedded structs with anIDfield produce JSON with noIDat 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.
In fact
- 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
Derivedwhere aBaseis expected; you passd.Base. - 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", andd.Describe()gave"describe: Base". - Not always. An embedded
Tpromotes methods with receiver*Tonly into the set of*S:ByValue{}does not implementSaver, while&ByValue{}does. With an embedded*Tboth do. - The declaration is legal; the reference
f.Close()is an error:ambiguous selector f.Close. But neither candidate becomes a promotedClose, and the interface method is not satisfied byFile: assigning it to an interface does not compile, and a type assertion returnsfalse. The explicit pathf.Reader.Close()works. - Not if the interface is embedded and the field is left
nil: in the runrc.Close()panicked withinvalid memory address or nil pointer dereference. The compile-time check sees the method set, not whether there is a value behind the field. - Neither: two untagged
IDfields at the same depth both vanish —{Title":"report}, and there is no error. That is exactly what the package documentation says. - 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.
What is covered
- What is really being asked
- Base: a field without a separate name
- Mechanism 1: embedding has no virtual call
- Mechanism 2: embedded `T` or `*T` — different method sets
- Mechanism 3: depth — and when identical names become an error
- Mechanism 4: an embedded interface — an implementation that panics on call
- Mechanism 5: what embedding exposes
- Deeper: the promoted call is indistinguishable from the direct one — the path is chosen at compile time
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
3 SOURCES
- 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
- 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
- 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