Go escape analysis and why my function return worked.

Go escape analysis and why my function return worked.

  • Go

Recently I came across something interesting in Go. I wrote the following code to read logs from a log file in the server.

func readLogsFromPartition(partition int) []LogEntry {
  var logs []LogEntry // Creating an innocent slice
  logs = ReadFromFile()

  return logs // Returning the slice
}

func main() {
  logs := readLogsFromPartition(1) // Using the slice in the main func
}

I compiled the program and ran it, and it worked. But after I took a step back and looked at it, I couldn't make sense of why it worked. Why am I able to return a value that was created on the local function back to the main function?

If you can't seem to understand why I'm confused, then I'll explain some background. Before my Go phase, I was trying to get back into writing C. And I had a few head scratching days of understanding the stack vs the heap.

From my understanding of C/C++, the above code would blow up. In C, you can't assign a value in a local function and then return it (if the value is a pointer). This is because when the function returns, all the stack values are destroyed, and hence the returned value will be replaced with garbage value.

Lets see an equivalent example of this in C:

#include <stdio.h>

int* readLogsFromPartition() {
    int logs[5] = {101, 102, 103, 104, 105};  // Local array on stack
    return logs;  // ⚠️ Returns pointer to local array!
}

int main() {
    int* logs = readLogsFromPartition();

    printf("First log: %d\n", logs[0]);  // UNDEFINED BEHAVIOR! Might print 101, might print garbage

    // Call another function that uses the stack...
    printf("Reading logs...\n");

    // Now the old stack memory is almost certainly overwritten
    for (int i = 0; i < 5; i++) {
        printf("Log %d: %d\n", i, logs[i]);  // Garbage values!
    }

    return 0;
}

As you can see, logs is a local array that we have defined in readLogsFromPartition. It will be initialized on the stack. Thus when the function readLogsFromPartition returns, internally the entry associated with readLogsFromPartition on the stack will be popped and cleared. So in the main function, we won't have a accurate value of logs. Instead we'll get garbage values.

In C, to avoid this, you'd initialize the variable in the calling function (on stack or heap) and then pass a pointer to the function. Then in the function, you'd dereference the variable and assign the value to it.

#include <stdio.h>
#include <stdlib.h>

void readLogsFromPartition(int* logs, int size) {
    // Populate the caller's pre-allocated array
    for (int i = 0; i < size; i++) {
        logs[i] = 100 + i;  // Simulating reading from file
    }
}

int main() {
    int logs[5];  // Caller allocates on stack
    // OR: int* logs = malloc(5 * sizeof(int));  // Heap allocation

    readLogsFromPartition(logs, 5);

    for (int i = 0; i < 5; i++) {
        printf("Log %d: %d\n", i, logs[i]);  // Safe!
    }

    return 0;
}

Or you can make the function allocate on the heap and return a pointer.

#include <stdio.h>
#include <stdlib.h>

int* readLogsFromPartition(int size) {
    int* logs = (int*)malloc(size * sizeof(int));  // Allocate on HEAP
    if (logs == NULL) {
        return NULL;
    }

    for (int i = 0; i < size; i++) {
        logs[i] = 100 + i;  // Simulating reading from file
    }

    return logs;  // Safe! Heap memory persists
}

int main() {
    int* logs = readLogsFromPartition(5);

    if (logs == NULL) {
        printf("Allocation failed!\n");
        return 1;
    }

    for (int i = 0; i < 5; i++) {
        printf("Log %d: %d\n", i, logs[i]);  // Safe!
    }

    free(logs);  // ⚠️ Caller MUST free, or memory leaks!
    return 0;
}

Now you can understand why my understanding of C would leave me scratching my head when I saw the Go code.

How Go handles this

func readLogsFromPartition(partition int) []LogEntry {
  var logs []LogEntry
  logs = ReadFromFile()

  return logs // Go knows that this might be referenced, so moves it to heap.
}

func main() {
  logs := readLogsFromPartition(1)
}

Go uses escape analysis at compile time. When the compiler sees that a variable (like logs) is returned from a function, it recognizes that the variable "escapes" the function scope. When a variable escapes:

Go allocates it on the heap (not the stack)

The garbage collector manages its lifetime

The memory stays alive as long as something references it, so when you do:

// Inside readLogsFromPartition
var logs []LogEntry       // Go sees this will escape
logs = append(all, log)
return logs        // Returned to caller

// At main
logs, err := readLogsFromPartition(partitionFilePath)
// `logs` now holds a reference to that heap-allocated slice
// The data lives on because `logs` references it

Why this is  safe

A slice in Go is actually a small struct (called a "slice header") containing:

  • pointer to the underlying array
  • The length
  • The capacity

image of the underlying structure of slice in go

When you return all, you're returning a copy of this slice header, but the pointer still points to the same underlying array on the heap. The garbage collector won't free that array until nothing references it anymore.

In C, returning a pointer to a local stack variable would be undefined behavior (dangling pointer). But Go's escape analysis and garbage collection specifically prevent this problem; it's one of Go's safety features.

So the next time you return a slice from a function in Go, know that the compiler has your back. You get the clean syntax without the danger.

References

claude-4.5 opus (Answered my initial query on why the code worked which triggered the hunt for understanding)

https://go.dev/blog/go-slices-usage-and-internals

https://go.dev/doc/faq#stack_or_heap