Why []T Won’t Pass as []interface{}

Go programmers frequently hit a wall when trying to pass a slice of a concrete type—say []int64—into a function that expects []interface{} or []fmt.Stringer. The compiler rejects it outright, and the reason is not a language limitation but a fundamental mismatch in how these two slice types are represented in memory.

The Official Stance

The Go FAQ puts it succinctly: "The two types do not have the same representation in memory." You cannot directly convert []T to []interface{}; you must copy each element individually.

The Go Wiki adds more detail. A []interface{} is not itself an interface—it's a slice whose element type happens to be interface{}. Each interface{} value occupies two machine words on a 64-bit system: one word for the type information, and one for the data or a pointer to it. Consequently, a []interface{} of length N is backed by N*2 words of data. A []MyType of the same length is backed by N*sizeof(MyType) words. The underlying buffers simply look different, so a quick assignment is impossible.

The Invariance Problem

There is a more theoretical angle. In subtyping terms, if type T implements interface I, you might expect []T to be assignable to []I—that is, you might expect slices to be covariant. Go slices are not; they are invariant.

This is not an oversight. Covariance combined with mutability leads to unsound behavior, as demonstrated by Java's covariant arrays. Consider valid Go code that assigns any type to an interface{}:

func foo(ii []interface{}) {
  ii[1] = "joe"
}

If slices were covariant, you could pass a []int64 to a function that writes an arbitrary interface{} into the slice:

ints := []int64{1, 2, 3}
foo(ints) // oops!

If that were legal, the function could assign a string into what is really an []int64, causing obscure runtime errors—exactly what statically typed languages try to prevent.

You might argue the problem only exists if the parameter is mutable. Go, however, has no const concept like C++, and even a read-only parameter would still encounter the memory layout issue.

The Memory Layout, In Detail

A Go slice is a header of three contiguous quadwords: a pointer to the backing data array, the length, and the capacity. The backing array holds elements contiguously. For a slice of four int64s, the data occupies four quadwords:

is := []int64{0x55, 0x22, 0xab, 0x9}
Slice memory layout holding int64

If the element type were a struct with three int64s, each element would occupy three quadwords, and the data region would span 12 quadwords total. Layout is strictly tied to the element type.

Now consider []interface{}. An interface{} value holds two pointers: one to an itable (the method dispatch table) and one to the underlying data. Each element therefore takes up two quadwords. Copying four int64s into an []interface{} produces a data region of eight quadwords:

iis := make([]interface{}, len(is))
for i := 0; i < len(is); i++ {
  iis[i] = is[i]
}
Slice memory layout holding interface{}

Each interface element's first quadword points to the itable; the second points to the actual int64 value. Code written to iterate over []interface{} would make a mess if handed an []int64 because the backing memory is entirely different.

Seeing It at Runtime

Memory dumps make the distinction concrete. Using Delve on a small program that builds both is and iis:

var sum int64

func addUpDirect(s []int64) {
  for i := 0; i < len(s); i++ {
    sum += s[i]
  }
}

func addUpViaInterface(s []interface{}) {
  for i := 0; i < len(s); i++ {
    sum += s[i].(int64)
  }
}

func main() {
  is := []int64{0x55, 0x22, 0xab, 0x9}

  addUpDirect(is)

  iis := make([]interface{}, len(is))
  for i := 0; i < len(is); i++ {
    iis[i] = is[i]
  }

  addUpViaInterface(iis)
}

Inspecting the slice header for &is:

$ dlv debug slice-layout.go
Type 'help' for list of commands.
(dlv) break slice-layout.go:35
Breakpoint 1 set at 0x467cb5 for main.main() ./slice-layout.go:35
(dlv) c
> main.main() ./slice-layout.go:35 (hits goroutine(1):1 total:1) (PC: 0x467cb5)
    30:               iis := make([]interface{}, len(is))
    31:               for i := 0; i < len(is); i++ {
    32:                       iis[i] = is[i]
    33:               }
    34:
=>  35:               addUpViaInterface(iis)
    36:       }
(dlv) p &is
(*[]int64)(0xc00003c748)
(dlv) x -fmt hex -len 32 0xc00003c748
0xc00003c748:   0x18   0xc7   0x03   0x00   0xc0   0x00   0x00   0x00
0xc00003c750:   0x04   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00003c758:   0x04   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00003c760:   0x00   0xe0   0x10   0x00   0xc0   0x00   0x00   0x00

Dumping the data region shows the four int64 values, one per quadword:

(dlv) x -fmt hex -len 32 0xc00003c718
0xc00003c718:   0x55   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00003c720:   0x22   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00003c728:   0xab   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00003c730:   0x09   0x00   0x00   0x00   0x00   0x00   0x00   0x00

For iis, the header shows the same length and capacity, but the data region is different:

(dlv) p &iis
(*[]interface {})(0xc00003c760)
(dlv) x -fmt hex -len 32 0xc00003c760
0xc00003c760:   0x00   0xe0   0x10   0x00   0xc0   0x00   0x00   0x00
0xc00003c768:   0x04   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00003c770:   0x04   0x00   0x00   0x00   0x00   0x00   0x00   0x00
0xc00003c778:   0xd0   0xc7   0x03   0x00   0xc0   0x00   0x00   0x00
(dlv) x -fmt hex -len 64 0xc00010e000
0xc00010e000:   0xe0   0xf1   0x46   0x00   0x00   0x00   0x00   0x00
0xc00010e008:   0xa8   0x44   0x4c   0x00   0x00   0x00   0x00   0x00
0xc00010e010:   0xe0   0xf1   0x46   0x00   0x00   0x00   0x00   0x00
0xc00010e018:   0x10   0x43   0x4c   0x00   0x00   0x00   0x00   0x00
0xc00010e020:   0xe0   0xf1   0x46   0x00   0x00   0x00   0x00   0x00
0xc00010e028:   0x58   0x47   0x4c   0x00   0x00   0x00   0x00   0x00
0xc00010e030:   0xe0   0xf1   0x46   0x00   0x00   0x00   0x00   0x00
0xc00010e038:   0x48   0x42   0x4c   0x00   0x00   0x00   0x00   0x00

The even-numbered quadwords all hold the same address—the itable for interface{}. The odd quadwords point to the actual data values:

(dlv) x -fmt hex -len 8 0x4c44a8
0x4c44a8:   0x55   0x00   0x00   0x00   0x00   0x00   0x00   0x00
(dlv) x -fmt hex -len 8 0x4c4310
0x4c4310:   0x22   0x00   0x00   0x00   0x00   0x00   0x00   0x00
(dlv) x -fmt hex -len 8 0x4c4758
0x4c4758:   0xab   0x00   0x00   0x00   0x00   0x00   0x00   0x00
(dlv) x -fmt hex -len 8 0x4c4248
0x4c4248:   0x09   0x00   0x00   0x00   0x00   0x00   0x00   0x00

Generated Code Is Different Too

The compiler also produces markedly different code for these two slice types, as disassembling the relevant functions shows.

For a direct []int64 access, the loop is simple—indexing into the linearly laid-out quadwords:

"".addUpDirect STEXT nosplit size=41 args=0x18 locals=0x0
  0x0000 00000 (slice-layout.go:11)       TEXT    "".addUpDirect(SB)
  0x0000 00000 (slice-layout.go:12)       MOVQ    "".s+16(SP), AX
  0x0005 00005 (slice-layout.go:12)       MOVQ    "".s+8(SP), CX
  0x000a 00010 (slice-layout.go:12)       XORL    DX, DX
  0x000c 00012 (slice-layout.go:12)       JMP     35
  0x000e 00014 (slice-layout.go:13)       MOVQ    "".sum(SB), BX
  0x0015 00021 (slice-layout.go:13)       ADDQ    (CX)(DX*8), BX
  0x0019 00025 (slice-layout.go:13)       MOVQ    BX, "".sum(SB)
  0x0020 00032 (slice-layout.go:12)       INCQ    DX
  0x0023 00035 (slice-layout.go:12)       CMPQ    DX, AX
  0x0026 00038 (slice-layout.go:12)       JLT     14
  0x0028 00040 (slice-layout.go:12)       RET

For []interface{}, the generated code is more complex. It must account for the two-word interface representation. The compiler uses an instruction like SHLQ $4, DX to multiply the loop index by 16 bytes per element, then accesses the second quadword (the data pointer) via an offset of 8 bytes:

"".addUpViaInterface STEXT size=150 args=0x18 locals=0x20
  0x0000 00000 (slice-layout.go:19)       TEXT    "".addUpViaInterface(SB)
  0x0000 00000 (slice-layout.go:19)       MOVQ    (TLS), CX
  0x0009 00009 (slice-layout.go:19)       CMPQ    SP, 16(CX)
  0x000d 00013 (slice-layout.go:19)       JLS     140
  0x000f 00015 (slice-layout.go:19)       SUBQ    $32, SP
  0x0013 00019 (slice-layout.go:19)       MOVQ    BP, 24(SP)
  0x0018 00024 (slice-layout.go:19)       LEAQ    24(SP), BP
  0x001d 00029 (slice-layout.go:20)       MOVQ    "".s+48(SP), AX
  0x0022 00034 (slice-layout.go:20)       MOVQ    "".s+40(SP), CX
  0x0027 00039 (slice-layout.go:20)       XORL    DX, DX
  0x0029 00041 (slice-layout.go:20)       JMP     64
  0x002b 00043 (slice-layout.go:21)       MOVQ    "".sum(SB), SI
  0x0032 00050 (slice-layout.go:21)       ADDQ    (DX), SI
  0x0035 00053 (slice-layout.go:21)       MOVQ    SI, "".sum(SB)
  0x003c 00060 (slice-layout.go:20)       LEAQ    1(BX), DX
  0x0040 00064 (slice-layout.go:20)       CMPQ    DX, AX
  0x0043 00067 (slice-layout.go:20)       JGE     103
  0x0045 00069 (slice-layout.go:21)       MOVQ    DX, BX
  0x0048 00072 (slice-layout.go:21)       SHLQ    $4, DX
  0x004c 00076 (slice-layout.go:21)       MOVQ    (CX)(DX*1), SI
  0x0050 00080 (slice-layout.go:21)       MOVQ    8(CX)(DX*1), DX
  0x0055 00085 (slice-layout.go:21)       LEAQ    type.int64(SB), DI
  0x005c 00092 (slice-layout.go:21)       NOP
  0x0060 00096 (slice-layout.go:21)       CMPQ    DI, SI
  0x0063 00099 (slice-layout.go:21)       JEQ     43
  0x0065 00101 (slice-layout.go:21)       JMP     113
  0x0067 00103 (slice-layout.go:20)       MOVQ    24(SP), BP
  0x006c 00108 (slice-layout.go:20)       ADDQ    $32, SP
  0x0070 00112 (slice-layout.go:20)       RET
  0x0071 00113 (slice-layout.go:21)       MOVQ    SI, (SP)
  0x0075 00117 (slice-layout.go:21)       MOVQ    DI, 8(SP)
  0x007a 00122 (slice-layout.go:21)       LEAQ    type.interface {}(SB), AX
  0x0081 00129 (slice-layout.go:21)       MOVQ    AX, 16(SP)
  0x0086 00134 (slice-layout.go:21)       CALL    runtime.panicdottypeE(SB)
  0x008b 00139 (slice-layout.go:21)       XCHGL   AX, AX
  0x008c 00140 (slice-layout.go:21)       NOP
  0x008c 00140 (slice-layout.go:19)       CALL    runtime.morestack_noctxt(SB)

Whether you read the assembly in detail, the takeaway is the same: the compiler's access patterns for []int64 and []interface{} are completely different. Passing the wrong slice type to compiled code would cause invalid memory access and crashes. This is not a theoretical concern; it is baked into the generated machine code.

A Path Forward With Generics

The practical need—one function operating on slices of many types—remains. The workaround is not type conversion tricks but generics, which allow writing a single function that works across concrete slice types without sacrificing type safety or memory layout correctness.