Introduction
Most language comparisons focus on execution speed, but memory allocation is often the more practical concern:
- Higher allocation → more GC pressure → unpredictable latency spikes
- Lower allocation → better cache utilization → cheaper hosting
- Allocation patterns → determine infrastructure requirements and scalability limits
This post compares total bytes allocated by Go 1.26.5 and .NET 10.0 SDK (10.0.302) running identical workloads on the same hardware. Both are the latest stable releases as of July 2026.
A note on methodology: These benchmarks use deliberately naive code in some cases to stress-test allocation behavior. While “optimized” versions exist for both languages, understanding the baseline allocation patterns helps developers make informed decisions about optimization priorities.
Methodology
Each benchmark function is wrapped with allocation tracking:
Go:
var m1, m2 runtime.MemStats
runtime.ReadMemStats(&m1)
primeBenchmark()
runtime.ReadMemStats(&m2)
fmt.Printf("Memory allocated: %d bytes\n", m2.TotalAlloc-m1.TotalAlloc)
.NET:
var startBytes = GC.GetTotalAllocatedBytes(false);
PrimeBenchmark();
var endBytes = GC.GetTotalAllocatedBytes(false);
Console.WriteLine($"Memory allocated: {endBytes - startBytes} bytes");
Both TotalAlloc and GetTotalAllocatedBytes measure cumulative bytes allocated since process start — they never decrease when GC runs. This captures every byte the runtime allocated, including intermediate objects that were later collected.
Results are averaged over 20 runs (two batches of 10) on a consistent hardware configuration.
Benchmark 1: Prime Number Calculation (CPU-bound)
Sieving primes up to 1,000,000 using trial division with optimizations (only odd divisors up to sqrt).
Go:
count := 0
for i := 2; i <= 1000000; i++ {
if isPrime(i) {
count++
}
}
func isPrime(number int) bool {
if number <= 1 { return false }
if number == 2 { return true }
if number%2 == 0 { return false }
boundary := int(math.Floor(math.Sqrt(float64(number))))
for i := 3; i <= boundary; i += 2 {
if number%i == 0 { return false }
}
return true
}
.NET:
var count = 0;
for (var i = 2; i <= 1000000; i++)
{
if (IsPrime(i)) { count++; }
}
static bool IsPrime(int number)
{
if (number <= 1) { return false; }
if (number == 2) { return true; }
if (number % 2 == 0) { return false; }
var boundary = (int)Math.Floor(Math.Sqrt(number));
for (var i = 3; i <= boundary; i += 2)
{
if (number % i == 0) { return false; }
}
return true;
}
Code: Simple loop with integer arithmetic — no heap allocations in the hot path.
| Metric | Go 1.26.5 | .NET 10.0.302 |
|---|---|---|
| Allocated | 147 B | 0 B |
| Time (avg) | 97 ms | 105 ms |
Both runtimes allocate essentially nothing. The 147 bytes Go reports are from runtime bookkeeping (timer creation, memstats collection) rather than the algorithm itself. .NET reports 0 bytes — the allocation is below the detection threshold of GetTotalAllocatedBytes.
Winner: Tie — neither runtime meaningfully allocates for this workload.
Benchmark 2: String Manipulation (Allocation-heavy)
100,000 iterations of string concatenation with truncation to 5,000 characters. This benchmark deliberately uses the worst-case approach to highlight allocation patterns.
Go:
result := ""
for i := 0; i < 100000; i++ {
result += fmt.Sprintf("%d", i)
if len(result) > 10000 { result = result[:5000] }
}
.NET:
var result = "";
for (var i = 0; i < 100000; i++)
{
result += i.ToString();
if (result.Length > 10000) { result = result[..5000]; }
}
Every += creates a new immutable string and discards the old one. Over 100,000 iterations this generates enormous allocation pressure.
| Metric | Go 1.26.5 | .NET 10.0.302 | Difference |
|---|---|---|---|
| Allocated | 751.6 MB | 1.4 GB | Go: -47% |
| Per iteration | ~7.7 KB | ~14.6 KB | |
| Time (avg) | 401 ms | 369 ms |
Go allocates 47% less memory than .NET for the same string workload. There are two main reasons:
String encoding: Go strings are UTF-8 (1 byte per ASCII character). .NET strings are UTF-16 (2 bytes per character). This doubles the raw string data size in memory.
Allocator efficiency: Go’s memory allocator handles the repeated small-string pattern more efficiently, with lower per-allocation overhead.
Timing is effectively a wash on this workload — .NET finishes slightly faster (369 ms vs 401 ms), with both runtimes spending most of the time servicing GC pressure from the churn (~750 MB in Go, ~1.4 GB in .NET).
Winner: Go — 47% less allocation.
The StringBuilder Middle Ground
The obvious first fix for the naive loop above is a growable buffer. In .NET that’s StringBuilder; Go’s equivalent is strings.Builder:
.NET:
using System.Text;
var sb = new StringBuilder(15000); // pre-allocate capacity -> no resizing
for (var i = 0; i < 100000; i++)
{
sb.Append(i); // no intermediary string allocations
if (sb.Length > 10000)
{
sb.Length = 5000; // truncate in place, instantly
}
}
var result = sb.ToString(); // single final allocation
Go:
var sb strings.Builder
sb.Grow(15000) // pre-allocate capacity
for i := 0; i < 100000; i++ {
sb.WriteString(strconv.Itoa(i))
if sb.Len() > 10000 {
s := sb.String()
if len(s) > 5000 { s = s[:5000] }
sb.Reset()
sb.WriteString(s) // no in-place truncation in Go
}
}
result := sb.String()
| Metric | Go strings.Builder | .NET StringBuilder |
|---|---|---|
| Allocated | 3.6 MB | 52 KB |
| Time (avg) | 5.6 ms | 11.6 ms |
Both eliminate the per-iteration string churn — allocation drops from 751.6 MB / 1.4 GB down to single-digit MB or less. .NET’s StringBuilder wins this round because sb.Length = 5000 truncates in place with zero copying, while Go’s strings.Builder has no length setter, so the truncation above copies the buffer each time it fires. Go pays for that in this specific truncating workload.
Winner: .NET — StringBuilder truncates in place; strings.Builder cannot.
The ReadOnlySpan Rewrite: Near-Zero Allocation
The naive += benchmark above deliberately stresses the worst case. In production, the same workload can be rewritten with advanced features:
.NET with ReadOnlySpan:
Span<char> buffer = stackalloc char[10000];
var length = 0;
for (var i = 0; i < 100000; i++)
{
if (!i.TryFormat(buffer[length..], out var written))
{
length = Math.Min(length, 5000);
continue;
}
length += written;
if (length > 10000)
{
length = Math.Min(length, 5000);
}
}
ReadOnlySpan<char> span = buffer[..length];
var result = span.ToString();
Go equivalent: pre-allocated []byte buffer:
buf := make([]byte, 0, 10000)
for i := 0; i < 100000; i++ {
buf = strconv.AppendInt(buf, int64(i), 10)
if len(buf) > 10000 {
buf = buf[:5000]
}
}
result := string(buf)
Three properties make this near-zero allocation:
stackallocplaces the buffer on the stack — no heap allocation for storage.TryFormatwrites each integer’s digits directly into the span, never creating an intermediate string per iteration.ReadOnlySpan<char>is a stack-only view over the buffer; the singleToString()at the end produces exactly one heap allocation.
Go’s analogous tool is strconv.AppendInt writing into a pre-allocated []byte, truncated in place by slicing (buf[:5000] — no copy) and materialized once at the end with string(buf).
| Metric | Go 1.26.5 naive | Go 1.26.5 buffer | .NET 10 naive += | .NET 10 ReadOnlySpan |
|---|---|---|---|---|
| Allocated | 751.6 MB | 22.6 KB | 1.4 GB | 16.5 KB |
| Time (avg) | 401 ms | 2.0 ms | 369 ms | <1 ms |
| Per iteration | ~7.7 KB | ~0.23 B | ~14.6 KB | ~0.17 B |
Both runtimes crush their naive versions. Go drops from 751.6 MB to 22.6 KB, .NET from 1.4 GB to 16.5 KB — roughly a 34,000× reduction for Go and an 88,000× reduction for .NET. The remaining bytes are essentially one allocation per runtime: Go’s pre-allocated 10 KB []byte backing array (heap, with small extra capacity growth when an append momentarily exceeds it before truncation) plus the final 8.4 KB string; .NET’s final string only, because stackalloc keeps the buffer off the heap entirely. Timing follows the same collapse, with most of the naive gap being GC pressure.
Winner: Tie — both runtimes reach effectively zero allocation with their idiomatic buffer patterns; the .NET edge is just the stack placement of stackalloc.
Benchmark 3: Mathematical Operations (CPU-bound)
10 million iterations of floating-point computation:
Go:
sum := 0.0
for i := 0; i < 10000000; i++ {
sum += math.Sqrt(float64(i))*math.Sin(float64(i)) + math.Cos(float64(i))/math.Sqrt(float64(i)+1)
}
.NET:
double sum = 0;
for (var i = 0; i < 10000000; i++)
{
sum += Math.Sqrt(i) * Math.Sin(i) + Math.Cos(i) / Math.Sqrt(i + 1);
}
| Metric | Go 1.26.5 | .NET 10.0.302 |
|---|---|---|
| Allocated | 152 B | 0 B |
| Time (avg) | 258 ms | 369 ms |
Virtually no allocation in either runtime. The math is computed entirely in CPU registers with stack-allocated doubles. The few bytes Go reports are again from runtime internals. .NET reports a clean 0 bytes here too — the Math.* calls compile to direct native instructions with no delegate or allocation overhead in .NET 10.
Winner: Tie — both near-zero allocation.
Benchmark 4: Collection Processing (Memory management)
Build a list of 1 million integers, filter for evens, then square each result — three full traversals with dynamic growth:
Go:
list := make([]int, 0)
for i := 0; i < 1000000; i++ { list = append(list, i) }
evenNumbers := make([]int, 0)
for _, x := range list { if x%2 == 0 { evenNumbers = append(evenNumbers, x) } }
squaredNumbers := make([]int64, 0)
for _, x := range evenNumbers { squaredNumbers = append(squaredNumbers, int64(x)*int64(x)) }
.NET:
var list = new List<int>();
for (var i = 0; i < 1000000; i++) { list.Add(i); }
var evenNumbers = new List<int>();
foreach (var x in list) { if (x % 2 == 0) { evenNumbers.Add(x); } }
var squaredNumbers = new List<long>();
foreach (var x in evenNumbers) { squaredNumbers.Add((long)x * x); }
| Metric | Go 1.26.5 | .NET 10.0.302 | Difference |
|---|---|---|---|
| Allocated | 80.0 MB | 20.0 MB | .NET: -75% |
| Time (avg) | 66 ms | 40 ms |
This is the largest gap in either direction. .NET allocates 75% less memory than Go for the same collection workload. The root cause is a subtle difference in growth strategy — and it runs counter to the common assumption.
Go’s append() grows large slices by only ~1.25× per reallocation (the simple doubling was replaced in Go 1.18). A smaller growth factor means more reallocations: the sum of all backing arrays is ≈ final size × g/(g−1), which is ~5× the final size at g = 1.25. Growing list (1M ints × 8 B = 8 MB) churns ~40 MB; evenNumbers (500K × 8 B = 4 MB) and squaredNumbers (500K × 8 B = 4 MB) churn ~20 MB each — about 80 MB total, matching the measured 83.8 MB.
.NET’s List<T> instead doubles each time it runs out of capacity. The churn is then only ≈2× the final size: 4 + 2 + 4 MB of finals → ~20 MB, again matching the measurement. Fewer, larger reallocations beat many small ones.
Winner: .NET — 75% less allocation and 1.6× faster.
Benchmark 5: Concurrency — Goroutines vs Async/Await
Spawning 10,000 concurrent operations to measure the stack and context allocation overhead of each runtime’s concurrency primitive:
Go: goroutines
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = work(i)
}(i)
}
wg.Wait()
.NET: async/await tasks
var tasks = new Task[10000];
for (var i = 0; i < 10000; i++)
{
tasks[i] = WorkAsync(i);
}
await Task.WhenAll(tasks);
static async Task WorkAsync(int i)
{
await Task.Yield();
_ = i * 2;
}
Each operation is deliberately trivial — the point is to isolate the cost of the concurrency primitive itself, not the work.
| Metric | Go 1.26.5 | .NET 10.0.302 | Difference |
|---|---|---|---|
| Allocated | 0.74 MB | 1.21 MB | Go: -39% |
| Per operation | ~77 B | ~127 B | |
| Time (avg) | 5.4 ms | 19 ms |
Go allocates 39% less for the same 10,000 concurrent operations. Two mechanisms explain the gap:
Goroutine stacks are tiny and reusable. Go starts each goroutine on a ~2 KB growable stack, but most of these come from the runtime’s stack cache, so the marginal allocation is only ~77 B per goroutine for this trivial workload.
A
Taskis three allocations. In .NET, eachasync Taskoperation allocates theTaskobject, the boxed async state machine, and a continuation — roughly 127 B per operation even with nothing to compute.
Winner: Go — 39% less allocation and 3.5× faster to spawn and complete.
Benchmark 6: Graph Processing — BFS/DFS
Traversing a 10,000-node graph (30,000 edges) with BFS and DFS, tracking the allocation cost of the visited-set data structure. The graph is built once before measurement so the metric isolates traversal allocation:
Go:
visited := make(map[int]bool, n) // visited set
queue := make([]int, 0, n) // BFS
queue = append(queue, 0)
visited[0] = true
for len(queue) > 0 {
node := queue[0]
queue = queue[1:]
for _, neighbor := range graph[node] {
if !visited[neighbor] {
visited[neighbor] = true
queue = append(queue, neighbor)
}
}
}
// DFS is identical with a stack: stack[len(stack)-1] / stack[:len(stack)-1]
.NET:
var visited = new HashSet<int>(n); // visited set
var queue = new Queue<int>(); // BFS
queue.Enqueue(0);
visited.Add(0);
while (queue.Count > 0)
{
var node = queue.Dequeue();
foreach (var neighbor in graph[node])
{
if (visited.Add(neighbor)) // Add returns true if new
{
queue.Enqueue(neighbor);
}
}
}
// DFS is identical with a Stack<int>
| Metric | Go 1.26.5 | .NET 10.0.302 |
|---|---|---|
| BFS Allocated | 369 KB | 158 KB |
| DFS Allocated | 470 KB | 223 KB |
| BFS Time (avg) | 0.9 ms | 13.0 ms |
| DFS Time (avg) | 1.1 ms | 11.8 ms |
This is the cleanest split of the whole series. .NET’s HashSet<int> is a compact flat array of int slots, allocating ~57% less than Go’s map[int]bool, which stores keys and values in hash buckets with per-bucket bookkeeping. Both visit all 10,000 nodes.
But Go is ~14× faster on BFS and ~11× on DFS. The slice-based queue/stack (queue[1:], stack[:len-1]) avoids the object-churn of Queue<int>/Stack<int>, and Go’s traversal loop produces near-zero GC pressure — the entire traversal completes in ~1 ms before the collector even notices.
Winner: .NET on allocation (−57%); Go on speed (up to 14×) — pick by whether your graph work is latency- or memory-bound.
Summary
| Benchmark | Go 1.26.5 | .NET 10.0.302 | Winner |
|---|---|---|---|
| Prime Calculation | 147 B | 0 B | Tie |
| String Manipulation (naive) | 751.6 MB | 1.4 GB | Go (−47%) |
| String Manipulation (StringBuilder) | 3.6 MB | 52 KB | .NET |
| String Manipulation (ReadOnlySpan) | 22.6 KB | 16.5 KB | .NET |
| Math Operations | 152 B | 0 B | Tie |
| Collection Processing | 80.0 MB | 20.0 MB | .NET (−75%) |
| Concurrency (10k ops) | 0.74 MB | 1.21 MB | Go (−39%) |
| Graph BFS | 369 KB | 158 KB | .NET (−57%) |
| Graph DFS | 470 KB | 223 KB | .NET (−53%) |
| Total Allocated | 833.2 MB | 1.4 GB | Go (−42%) | | Total excl. String | 81.5 MB | 21.6 MB | .NET (−74%) |
Key Takeaways
Your workload dictates the winner.
- String-heavy applications allocate 47% less in Go
- Data-processing applications allocate 75% less in .NET
- Concurrent workloads allocate 39% less in Go
- Hash-set workloads allocate 57% less in .NET
String concatenation with
+=is harmful in both languages.- These benchmarks use deliberately naive code to stress-test allocation
- In production, use
strings.Builder(Go) orStringBuilder(.NET) to cut allocation by orders of magnitude — 3.6 MB and 52 KB respectively here - Go one-ups this with a pre-allocated
[]byte+strconv.AppendInt(22.6 KB) - .NET with
ReadOnlySpan<char>+stackalloc+TryFormat(16.5 KB) - Both drop string workloads from ~750 MB–1.4 GB down to kilobytes
Go’s slice growth is counterintuitively expensive.
- Go grows large slices by only ~1.25× per reallocation, so churn is ~5× the final size — 80 MB in this test
- .NET’s
List<T>doubles (2×), limiting churn to ~2× the final size — 20 MB here - Fewer, larger reallocations beat many small ones; if you know the final size, use
make([]T, 0, size)to pre-allocate
CPU-bound workloads don’t distinguish the two.
- Prime sieving and math operations allocate near zero in both runtimes
- Choose based on other factors (ecosystem, developer experience, deployment model)
Total allocation is dominated by a single category.
- In these benchmarks, string manipulation accounts for 90%+ of all bytes allocated
- Optimizing that one category would dwarf any other improvement
Goroutines are the cheaper concurrency primitive.
- At 10,000 concurrent operations, Go allocates 0.74 MB vs .NET’s 1.21 MB for async/await
- The gap widens as the operation count grows — each goroutine is ~77 B, each async task ~127 B
- Go is also 3.5× faster to spawn and complete
Hash-based data structures are smaller in .NET.
HashSet<int>allocates ~57% less than Go’smap[int]boolfor the same 10,000-node visited set- But Go’s traversal ran up to 14× faster using slice-based queues/stacks
- Choose based on whether you’re memory- or latency-bound
The Bottom Line
Neither runtime is universally more memory-efficient:
- Go wins on string-heavy workloads by 47% (naive) and on concurrent workloads by 39%
- .NET wins on collection-heavy workloads by 75% and on hash-set workloads by 57%
- Both achieve near-zero allocation with proper optimization techniques
For mixed workloads, measure your specific pattern. The difference between naive and optimized code (up to 88,000×) is far larger than the difference between runtimes (~42%).
Performance tip: Understanding allocation patterns in your domain is worth more than choosing the “right” language. Most teams will see better results from optimizing hot paths than from switching runtimes.
