December 2025
Advent of Code 2025, using Go
The Advent of Code is an annual programming event with fun daily puzzles leading up to Christmas. This year’s event consisted of only 12 problems instead of the usual 25. In 2025, I teamed up again with friends and colleagues to participate. After enjoying J (2021), Clojure (2022), and Rust (2024) in previous years, I decided to use this year’s event as an excuse to write some Go.
Go
Go is statically typed, compiled, and known for its simple syntax and concurrency model. Coming from Rust and C, the biggest shifts are Go’s garbage collector and its incredibly fast build times: compiling feels nearly instantaneous compared to waiting 10 seconds or more for a Rust build. It also packs a much more extensive standard library than C, giving you out-of-the-box access to features like regex, big integers, and hash maps.
My goals for this AoC
Besides wanting to get more practice in Go, the main draw was an informal competition with my coworkers. Brainstorming approaches and comparing solutions every day is a huge motivator. As every year, we set up an automated benchmark system using GitHub Runners and hyperfine to build and test our code straight from a shared repository.
This year, our language breakdown was: five on Rust, three on Python, and one each for C, Java, and Swift.
Performance-wise, C and Rust consistently took the lead, with Go close behind. Java and Swift were usually a bit slower, and Python was generally well behind. On the readability front, I really appreciated Go’s simplicity, though I missed Rust’s expressiveness (even if it comes with higher complexity).
Go vs Rust
To get a real sense of how the languages compare for puzzle-solving, I wrote every solution in both Go and Rust. Here is what stood out:
- Go is simple. I really like simplicity. It means fewer surprises when reading someone else’s code (or my own, six months from now). Plus, Go’s auto-formatter ensures everyone’s code looks essentially identical.
- Go is fast. While my Rust binaries were ultimately faster at runtime, Go’s compile times were practically zero. Waiting tens of seconds for Rust builds during rapid iteration gets old fast.
- Go is verbose. My Go solutions were consistently 30–60% longer than the Rust equivalents. It is a natural trade-off for having less complex syntax, but Rust is undeniably more expressive.
To illustrate the verbosity: in Go, I often had to write nested for loops instead of relying on compact iterator chains. A single .map().filter().sum() easily replaces 5–10 lines of Go.
Similarly, Rust’s itertools gave me .tuples(), .combinations(), or .tuple_windows() out of the box. Without direct Go equivalents, I had to write these out manually using verbose loops.
Finally, Go’s explicit error handling creates a lot of boilerplate. I found myself duplicating this pattern often:
data, err := os.ReadFile(filename)
if err != nil {
panic(err)
}
Overall, though, I liked Go. Its concurrency features play out well when implementing web services and APIs. And for CLI tools, compiling down to a single, statically linked binary with no runtime dependencies is very convenient.
Advent of Code
You can read the daily problem descriptions on the AoC website. Each of my solutions prints two numbers corresponding to the two parts of the problem. My solutions are also available on GitHub.
For each day, I wrote an advent function with a signature like this:
func day01(filename string) (int, int)
It reads the problem input file and returns the answers for part one and two. I calculate both in a single function because part two usually builds directly on part one.
In my main.go file, I grab the input filename from the command line arguments and pass it to the correct day’s function:
var days = map[int]func(string) (int, int){
1: day01,
2: day02,
3: day03,
4: day04,
5: day05,
6: day06,
7: day07,
8: day08,
9: day09,
10: day10,
11: day11,
12: day12,
}
func main() {
day, _ := strconv.Atoi(defaultDay)
input := fmt.Sprintf("inputs/%02d.txt", day)
if len(os.Args) >= 2 {
input = os.Args[1]
}
solve, ok := days[day]
if !ok {
fmt.Fprintf(os.Stderr, "Day %d not implemented\n", day)
os.Exit(1)
}
p1, p2 := solve(input)
fmt.Printf("%d\n%d\n", p1, p2)
}
Day 1
Almost every Advent of Code puzzle boils down to two steps: parsing the input and solving the actual problem.
For parsing, I relied on Go’s standard library alongside a custom extractNumbers helper that pulls every number from a string into an array. This utility was very useful in previous years and will for sure be handy in the upcoming days as well.
The core puzzle logic involves moving either left or right on a dial. Since the two directions are symmetric, I simplified the code by reflecting the dial position to convert any left movement into a right movement. That way, I only needed to implement the logic for one direction.
func day01(filename string) (int, int) {
var part1, part2 int
dial := 50
for _, line := range readLines(filename) {
dir := 1
if line[0] == 'L' {
dir = -1
}
dist := extractSignedNumber(line)
// There are two cases: Going LEFT and going RIGHT on the dial.
//
// They are symmetric, so we transform the LEFT case into the
// RIGHT case by reflecting the dial position: (100-dial)%100.
part2 += (modEuclid(dir*dial, 100) + dist) / 100
dial += dir * dist
if dial%100 == 0 {
part1++
}
}
return part1, part2
}
Day 2
In part one, we need to find out if a number consists of a sequence of digits repeated exactly twice. To solve this, I convert the number to a string and simply check if the first half matches the second half.
Part two extends this by asking if a number is a sequence of digits repeated twice or more. I tackled this by reusing the string representation from part one and applying a classic string periodicity trick. If a string s is periodic, then s will appear inside the concatenation s + s (excluding the very beginning and end). By stripping the first and last characters of the doubled string and checking if s is still a substring, we can easily detect repeating sequences.
func day02(filename string) (int, int) {
numbers := extractNumbers(readFile(filename))
var part1, part2 int
for i := 0; i+1 < len(numbers); i += 2 {
start, end := numbers[i], numbers[i+1]
for id := start; id <= end; id++ {
s := strconv.Itoa(id)
n := len(s)
// Part 1
if n%2 == 0 {
half := n / 2
if s[:half] == s[half:] {
part1 += id
}
}
// Part 2
doubled := s + s
if strings.Contains(doubled[1:len(doubled)-1], s) {
part2 += id
}
}
}
return part1, part2
}
Day 3
This puzzle asks us to pick n digits from a string of digits to form the largest possible number. It is a classic greedy problem. The trick is to build the number from left to right, picking the largest available digit at each step while ensuring there are still enough digits left in the string to finish building the number. Part one requires picking 2 digits, while part two bumps it up to 12.
func maxJoltage(bank string, n int) int {
start := 0
joltage := 0
for end := len(bank) - n + 1; end <= len(bank); end++ {
maxIdx := start
maxVal := bank[start]
for i := start + 1; i < end; i++ {
if bank[i] > maxVal {
maxIdx = i
maxVal = bank[i]
if maxVal == '9' {
break // can't do better than 9
}
}
}
start = maxIdx + 1
joltage = joltage*10 + int(maxVal-'0')
}
return joltage
}
func day03(filename string) (int, int) {
var part1, part2 int
for _, line := range readLines(filename) {
part1 += maxJoltage(line, 2)
part2 += maxJoltage(line, 12)
}
return part1, part2
}
Day 4
We are given a grid containing rolls marked with @. For part one, we need to find “accessible” rolls (defined as those with fewer than 4 neighbors) so they can be removed.
Part two introduces a cascading effect: when a roll is removed, its neighbors lose a neighbor. If a neighbor’s count drops below the threshold of 4, it also becomes removable. I solved this using a stack-based flood-fill (DFS), modifying the grid in place to store the live neighbor count directly in each cell.
func day04(filename string) (int, int) {
grid := NewGrid(readLines(filename))
var todo []Point
// Part 1: Find accessible rolls (< 4 neighbors)
for r := 0; r < grid.Height; r++ {
for c := 0; c < grid.Width; c++ {
p := Point{r, c}
if grid.At(p) != '@' {
continue
}
var n byte
for _, d := range Dirs8 {
nb := p.Add(d)
if grid.Contains(nb) && grid.At(nb) != '.' {
n++
}
}
if n < 4 {
todo = append(todo, p)
} else {
grid.Set(p, n) // store raw neighbor count (4–8)
}
}
}
part1 := len(todo)
part2 := 0
// Part 2: Cascade removal
for len(todo) > 0 {
p := todo[len(todo)-1]
todo = todo[:len(todo)-1]
part2++
for _, d := range Dirs8 {
nb := p.Add(d)
if !grid.Contains(nb) {
continue
}
if grid.At(nb) == 4 { // will drop below threshold
todo = append(todo, nb)
}
grid.Set(nb, grid.At(nb)-1)
}
}
return part1, part2
}
Day 5
Here, we are given a list of numeric ranges and a set of IDs to check. The first step is to sort and merge these ranges into a consolidated list of non-overlapping intervals. Once that is done, part one is just a matter of running a binary search on each ID to see if it falls within any of the merged ranges. For part two, we simply calculate the total size of all the merged ranges and sum them up.
func day05(filename string) (int, int) {
rangeLines, checkLines := splitAtEmptyLine(readLines(filename))
// Parse and sort ranges
type Range struct{ start, end int }
var ranges []Range
for _, line := range rangeLines {
nums := extractNumbers(line)
ranges = append(ranges, Range{nums[0], nums[1]})
}
slices.SortFunc(ranges, func(a, b Range) int {
return a.start - b.start
})
// Merge overlapping ranges
merged := []Range{ranges[0]}
for _, r := range ranges[1:] {
last := &merged[len(merged)-1]
if r.start <= last.end {
last.end = max(last.end, r.end)
} else {
merged = append(merged, r)
}
}
// Part 1: Binary search in merged ranges
part1 := 0
for _, line := range checkLines {
id := extractNumber(line)
idx := sort.Search(len(merged), func(i int) bool {
return merged[i].start > id
})
if idx > 0 && id <= merged[idx-1].end {
part1++
}
}
// Part 2: Sum merged range sizes
part2 := 0
for _, r := range merged {
part2 += r.end - r.start + 1
}
return part1, part2
}
Day 6
This puzzle features a grid with columns of digits, separated by operators (+ or *) located on the bottom row. To parse it, I iterate through the operator row from right to left, isolating the spans of columns between each operator.
For part one, we read the digits inside each span horizontally (row by row) to form numbers, and then apply the corresponding operator. Part two flips the orientation, requiring us to read the same spans vertically (column by column). Simple sum and product helper functions handle the final math for each group.
func day06(filename string) (int, int) {
grid := NewGrid(readLines(filename))
h := grid.Height - 1 // operator row index
w := grid.Width
var p1, p2 int
r := w
// Walk operator row right-to-left, processing spans [l, r)
for l := w - 1; l >= 0; l-- {
op := grid.At(Point{h, l})
if op == ' ' {
continue
}
nums := make([]int, 0, max(h, r-l))
// Part 1: read numbers horizontally (row by row, left to right)
for y := range h {
n := 0
for x := l; x < r; x++ {
if d := grid.At(Point{y, x}); d != ' ' {
n = 10*n + int(d-'0')
}
}
nums = append(nums, n)
}
if op == '+' {
p1 += sum(nums)
} else {
p1 += product(nums)
}
// Part 2: read numbers vertically (col by col, top to bottom)
nums = nums[:0]
for x := l; x < r; x++ {
n := 0
for y := range h {
if d := grid.At(Point{y, x}); d != ' ' {
n = 10*n + int(d-'0')
}
}
nums = append(nums, n)
}
if op == '+' {
p2 += sum(nums)
} else {
p2 += product(nums)
}
r = l - 1
}
return p1, p2
}
Day 7
We are tracking downward-traveling tachyon beams using a boolean array indexed by column. My grid helper FindAll('^') conveniently returns splitters in top-to-bottom order, which matches the natural flow of the beams. When a beam hits a splitter, it stops and fires two new beams to the left and right. Part one simply asks for the total number of splits.
For part two, we process the splitters in reverse (bottom to top) with dynamic programming. Each column starts with dp[c] = 1, representing a beam successfully exiting the grid to create one timeline. As we work backward up the grid, each splitter updates the state using dp[c] = dp[c-1] + dp[c+1], reflecting the split into left and right timelines. The final answer is the value in the dp array at the starting column.
func day07(filename string) (int, int) {
grid := NewGrid(readLines(filename))
start := grid.Find('S')
beams := make([]bool, grid.Width)
beams[start.C] = true
var splitters []Point
for _, sp := range grid.FindAll('^') {
if beams[sp.C] {
// Split: stop this beam, emit left and right
beams[sp.C] = false
beams[sp.C-1] = true
beams[sp.C+1] = true
splitters = append(splitters, sp)
}
}
part1 := len(splitters)
// Part 2: Process splitters in reverse order (bottom to top).
// For each column, track the timeline count for the lowest
// reachable splitter.
dp := slices.Repeat([]int{1}, grid.Width)
for i := len(splitters) - 1; i >= 0; i-- {
c := splitters[i].C
dp[c] = dp[c-1] + dp[c+1]
}
part2 := dp[start.C]
return part1, part2
}
Day 8
This is Kruskal’s minimum spanning tree on 3D points. First, I generate all pairwise edges and sort them by their squared Euclidean distance (skipping the expensive sqrt calculation, since we only care about the ordering). Then, I process the edges using a union-find data structure. For part one, after connecting the 1000 shortest pairs, the answer is the product of the three largest connected component sizes. Part two is the product of the X coordinates of the two boxes whose connection completes the spanning tree.
func day08(filename string) (int, int) {
nums := extractNumbers(readFile(filename))
n := len(nums) / 3
boxes := make([][3]int, n)
for i := range n {
boxes[i] = [3]int{nums[3*i], nums[3*i+1], nums[3*i+2]}
}
// Build and sort all edges by squared distance
type Edge struct{ dist, i, j int }
edges := make([]Edge, 0, n*(n-1)/2)
for i := range n {
for j := i + 1; j < n; j++ {
dx := boxes[i][0] - boxes[j][0]
dy := boxes[i][1] - boxes[j][1]
dz := boxes[i][2] - boxes[j][2]
edges = append(edges, Edge{dx*dx + dy*dy + dz*dz, i, j})
}
}
slices.SortFunc(edges, func(a, b Edge) int { return a.dist - b.dist })
// Kruskal: process edges in distance order
uf := NewUnionFind(n)
var p1, p2 int
processed := 0
for _, e := range edges {
if uf.Unite(e.i, e.j) && uf.count == 1 {
p2 = boxes[e.i][0] * boxes[e.j][0]
break
}
processed++
if processed == 1000 && p1 == 0 {
p1 = product(uf.TopSizes(3))
}
}
return p1, p2
}
Day 9
The red tiles in this puzzle map out the vertices of a rectilinear polygon. For part one, I check all pairs of vertices and return the largest bounding-box area (no constraints). Part two complicates things by requiring that this bounding box does not cross any of the polygon’s edges (meaning it has to be entirely inside or entirely outside the polygon). I represent both vertex pairs and edges as axis-aligned bounding boxes, sort pairs by area descending, and return the first pair whose interior doesn’t intersect any edge.
type Rect struct{ xMin, yMin, xMax, yMax int }
func newRect(ax, ay, bx, by int) Rect {
return Rect{min(ax, bx), min(ay, by), max(ax, bx), max(ay, by)}
}
func (r Rect) area() int {
return (r.xMax - r.xMin + 1) * (r.yMax - r.yMin + 1)
}
func (r Rect) intersectsInterior(e Rect) bool {
return e.xMin < r.xMax && e.yMin < r.yMax && e.xMax > r.xMin && e.yMax > r.yMin
}
func day09(filename string) (int, int) {
nums := extractNumbers(readFile(filename))
n := len(nums) / 2
// Red tile vertices
vx := make([]int, n)
vy := make([]int, n)
for i := range n {
vx[i], vy[i] = nums[2*i], nums[2*i+1]
}
// All pairs of vertices, sorted by area descending
pairs := make([]Rect, 0, n*(n-1)/2)
for i := range n {
for j := i + 1; j < n; j++ {
pairs = append(pairs, newRect(vx[i], vy[i], vx[j], vy[j]))
}
}
slices.SortFunc(pairs, func(a, b Rect) int { return b.area() - a.area() })
// Polygon edges (consecutive vertices, wrapping)
edges := make([]Rect, n)
for i := range n {
j := (i + 1) % n
edges[i] = newRect(vx[i], vy[i], vx[j], vy[j])
}
slices.SortFunc(edges, func(a, b Rect) int { return b.area() - a.area() })
// Part 1: Largest pair area
p1 := pairs[0].area()
// Part 2: Largest pair that doesn't cross any polygon edge
p2 := 0
for _, r := range pairs {
crosses := slices.ContainsFunc(edges, r.intersectsInterior)
if !crosses {
p2 = r.area()
break
}
}
return p1, p2
}
Day 10
Each machine has buttons that affect a set of counters or lights. For part one (lights), toggling is mod 2, so I brute-force all 2^B button subsets and find the smallest whose parity effect matches the target light pattern.
For part two (joltage counters), we need exact non-negative integer targets. Brute force won’t work here. The key insight is that the target’s parity constrains which button subsets are actually valid at each step. By subtracting a valid subset’s effect from the target, the remainder becomes even and can be safely halved, allowing for an efficient recursive solution.
const inf = math.MaxInt / 8
type buttonSubset struct {
presses int // subset size
effect []int // integer increments from pressing each button in subset once
}
// parityMask encodes v mod 2 as a bitmask (bit i = parity of v[i]).
func parityMask(v []int) uint64 {
var m uint64
for i, x := range v {
if x%2 != 0 {
m |= 1 << i
}
}
return m
}
func day10(filename string) (int, int) {
var p1, p2 int
for _, line := range readLines(filename) {
parts := strings.Fields(line)
// Parse target light pattern: # -> 1, . -> 0
var lights uint64
for i, b := range strings.Trim(parts[0], "[]") {
if b == '#' {
lights |= 1 << i
}
}
// Parse buttons as "which indices this button affects"
buttons := make([][]int, len(parts)-2)
for i, s := range parts[1 : len(parts)-1] {
buttons[i] = extractNumbers(s)
}
target := extractNumbers(parts[len(parts)-1])
dim := len(target)
// Precompute: for every parity pattern, which subsets achieve it (and their effects).
subsetsByParity := map[uint64][]buttonSubset{}
for mask := range 1 << len(buttons) {
effect := make([]int, dim)
presses := 0
for i, btn := range buttons {
if (mask>>i)&1 == 1 {
presses++
for _, idx := range btn {
if idx < dim {
effect[idx]++
}
}
}
}
p := parityMask(effect)
subsetsByParity[p] = append(subsetsByParity[p], buttonSubset{presses, effect})
}
// Part 1: minimum-size subset matching target parity
if options, ok := subsetsByParity[lights]; ok {
best := inf
for _, s := range options {
best = min(best, s.presses)
}
p1 += best
}
// Part 2: DP + memo
p2 += minPresses(target, subsetsByParity, map[string]int{})
}
return p1, p2
}
// minPresses returns the fewest total button presses to reach target exactly.
//
// Recurrence:
// - Let parity = target mod 2. We must choose a subset with this parity-effect.
// - Subtract its integer effect E; now the remainder is even.
// - Divide remainder by 2 (since it is even).
// - Cost adds number of current presses plus `2 * cost(next)` (to account for division by 2).
func minPresses(target []int, subsetsByParity map[uint64][]buttonSubset, cache map[string]int) int {
allZero := true
for _, x := range target {
if x < 0 {
return inf // invalid path
}
if x != 0 {
allZero = false
}
}
if allZero {
return 0 // base case (target reached)
}
key := fmt.Sprint(target)
if ans, ok := cache[key]; ok {
return ans // cached path
}
// Try all button subsets that match the target's current parity
best := inf
if options, ok := subsetsByParity[parityMask(target)]; ok {
next := make([]int, len(target))
for _, subset := range options {
for i := range target {
next[i] = (target[i] - subset.effect[i]) / 2
}
best = min(best, subset.presses+2*minPresses(next, subsetsByParity, cache))
}
}
cache[key] = best
return best
}
Day 11
The input describes a directed acyclic graph of devices. Part one is a classic pathfinding problem, so I count all distinct paths from you to out using simple recursion.
Part two adds a constraint: we now have to count paths from svr to out, but they must pass through both the dac and fft nodes. I extend the recursion with two boolean flags tracking whether each node has been visited, and only count a path when it reaches out with both flags set to true. To prevent the recursion from blowing up, I memoized the results based on the state: (node, dac_seen, fft_seen).
type Node = [3]byte
func toNode(s string) Node {
return Node{s[0], s[1], s[2]}
}
func day11(filename string) (int, int) {
graph := map[Node][]Node{}
for _, line := range readLines(filename) {
parts := strings.Fields(line)
from := toNode(parts[0])
neighbors := make([]Node, len(parts)-1)
for i, s := range parts[1:] {
neighbors[i] = toNode(s)
}
graph[from] = neighbors
}
p1 := countPaths(toNode("you"), toNode("out"), graph)
p2 := countPaths2(toNode("svr"), toNode("out"), graph, false, false, map[cacheKey]int{})
return p1, p2
}
// countPaths returns the number of distinct paths from -> to in a DAG.
func countPaths(from, to Node, g map[Node][]Node) int {
if from == to {
return 1
}
neighbors, ok := g[from]
if !ok {
return 0
}
total := 0
for _, n := range neighbors {
total += countPaths(n, to, g)
}
return total
}
type cacheKey struct {
node Node
dac, fft bool
}
// countPaths2 counts paths from -> to that visit both "dac" and "fft".
func countPaths2(from, to Node, g map[Node][]Node, dac, fft bool, cache map[cacheKey]int) int {
dac = dac || from == toNode("dac")
fft = fft || from == toNode("fft")
key := cacheKey{from, dac, fft}
if ans, ok := cache[key]; ok {
return ans
}
var result int
if from == to {
if dac && fft {
result = 1
}
} else if neighbors, ok := g[from]; ok {
for _, n := range neighbors {
result += countPaths2(n, to, g, dac, fft, cache)
}
}
cache[key] = result
return result
}
Day 12
As is tradition for the final day, there is only one part to solve. Each present fits in a 3x3 bounding box, so (w/3) * (h/3) gives us the absolute maximum number of presents a given region can hold. We simply check whether each region has enough capacity for its required presents. Note that this heuristic only solves the real puzzle input, not the example!
func day12(filename string) (int, int) {
nums := extractNumbers(readFile(filename))
p1 := 0
for i := 6; i+7 < len(nums); i += 8 {
if (nums[i]/3)*(nums[i+1]/3) >= sum(nums[i+2:i+8]) {
p1++
}
}
return p1, 0 // no part 2
}