Skip to main content

Dijkstra's Algorithm in Go: Implementation, Optimization, and Real-World Use

·9 mins
Table of Contents
Graph shortest-path problems appear constantly in platform engineering: network routing, dependency resolution, service mesh path optimization, and CI pipeline scheduling all reduce to finding the minimum-cost path through a directed weighted graph. Dijkstra’s algorithm is the workhorse for these problems when edge weights are non-negative. This post covers a correct Go implementation, the heap-based optimization that makes it practical on large graphs, path reconstruction, and a realistic dependency-resolution use case.

Graph Representation
#

An adjacency list using integer node IDs is the right choice for Dijkstra in Go. String keys are convenient for small examples but add unnecessary hashing overhead on graphs with thousands of nodes. We represent weighted edges as structs and store the graph as a slice of slices (indexed by node ID).

graph.go
package graph

const Infinity = int(^uint(0) >> 1)

// Edge represents a directed weighted connection from one node to another.
type Edge struct {
    To     int
    Weight int
}

// Graph is an adjacency list indexed by node ID.
type Graph struct {
    Edges [][]Edge
    Size  int
}

func NewGraph(n int) *Graph {
    return &Graph{
        Edges: make([][]Edge, n),
        Size:  n,
    }
}

// AddEdge adds a directed edge. For undirected graphs, call twice.
func (g *Graph) AddEdge(from, to, weight int) {
    g.Edges[from] = append(g.Edges[from], Edge{To: to, Weight: weight})
}

Naive O(V²) Implementation
#

The naive approach maintains an unvisited set and scans it linearly on every iteration to find the node with the minimum tentative distance. This is O(V) per iteration, giving O(V²) overall. It is acceptable for dense graphs (E close to V²) but impractical for sparse graphs with millions of nodes.

dijkstra_naive.go
package graph

// DijkstraNaive returns the shortest distance from start to every reachable node.
// Time complexity: O(V^2). Use DijkstraHeap for large sparse graphs.
func DijkstraNaive(g *Graph, start int) (dist []int, prev []int) {
    n := g.Size
    dist = make([]int, n)
    prev = make([]int, n)
    visited := make([]bool, n)

    for i := range dist {
        dist[i] = Infinity
        prev[i] = -1
    }
    dist[start] = 0

    for range n {
        // Find the unvisited node with the smallest tentative distance.
        u := -1
        for v := 0; v < n; v++ {
            if !visited[v] && (u == -1 || dist[v] < dist[u]) {
                u = v
            }
        }

        if u == -1 || dist[u] == Infinity {
            break // All remaining nodes are unreachable.
        }
        visited[u] = true

        for _, e := range g.Edges[u] {
            if alt := dist[u] + e.Weight; alt < dist[e.To] {
                dist[e.To] = alt
                prev[e.To] = u
            }
        }
    }

    return dist, prev
}

Priority Queue Optimization: O((E+V) log V)
#

Replacing the linear scan with a min-heap reduces the complexity from O(V²) to O((E+V) log V). Go’s container/heap package requires implementing the heap.Interface, which is five methods. The key insight: we do not decrease-key in the heap (that would require tracking indices). Instead we push a new entry and skip stale entries when they are popped.

dijkstra_heap.go
package graph

import "container/heap"

// heapItem is an entry in the priority queue.
type heapItem struct {
    node int
    dist int
}

type minHeap []heapItem

func (h minHeap) Len() int            { return len(h) }
func (h minHeap) Less(i, j int) bool  { return h[i].dist < h[j].dist }
func (h minHeap) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }

func (h *minHeap) Push(x any) {
    *h = append(*h, x.(heapItem))
}

func (h *minHeap) Pop() any {
    old := *h
    n := len(old)
    item := old[n-1]
    *h = old[:n-1]
    return item
}

// DijkstraHeap returns shortest distances and the predecessor map from start.
// Time complexity: O((E + V) log V).
func DijkstraHeap(g *Graph, start int) (dist []int, prev []int) {
    n := g.Size
    dist = make([]int, n)
    prev = make([]int, n)

    for i := range dist {
        dist[i] = Infinity
        prev[i] = -1
    }
    dist[start] = 0

    pq := &minHeap{{node: start, dist: 0}}
    heap.Init(pq)

    for pq.Len() > 0 {
        item := heap.Pop(pq).(heapItem)
        u := item.node

        // Skip stale entries (a shorter path was already found).
        if item.dist > dist[u] {
            continue
        }

        for _, e := range g.Edges[u] {
            if alt := dist[u] + e.Weight; alt < dist[e.To] {
                dist[e.To] = alt
                prev[e.To] = u
                heap.Push(pq, heapItem{node: e.To, dist: alt})
            }
        }
    }

    return dist, prev
}
Important

The stale-entry check (if item.dist > dist[u]) is critical. Without it, you process the same node multiple times with outdated distances, producing incorrect results. This is the most common bug in Dijkstra heap implementations.

Path Reconstruction
#

The prev array returned by both implementations records the predecessor of each node on the shortest path. To reconstruct the actual path from start to end, walk backwards through prev and reverse.

path.go
package graph

// ReconstructPath returns the node sequence on the shortest path from start to end.
// Returns nil if end is unreachable from start.
func ReconstructPath(prev []int, start, end int) []int {
    if prev[end] == -1 && end != start {
        return nil // end is unreachable
    }

    var path []int
    for at := end; at != -1; at = prev[at] {
        path = append(path, at)
    }

    // Reverse in place.
    for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
        path[i], path[j] = path[j], path[i]
    }
    return path
}

Full usage:

main.go
package main

import (
    "fmt"
    "graph"
)

func main() {
    g := graph.NewGraph(6) // nodes 0-5

    g.AddEdge(0, 1, 7)
    g.AddEdge(0, 2, 9)
    g.AddEdge(0, 5, 14)
    g.AddEdge(1, 2, 10)
    g.AddEdge(1, 3, 15)
    g.AddEdge(2, 3, 11)
    g.AddEdge(2, 5, 2)
    g.AddEdge(3, 4, 6)
    g.AddEdge(4, 5, 9)

    dist, prev := graph.DijkstraHeap(g, 0)

    fmt.Println("Distance from 0 to 4:", dist[4]) // 26
    path := graph.ReconstructPath(prev, 0, 4)
    fmt.Println("Path:", path) // [0 2 5 4] -- but wait, 5->4 is not in our edges
    // Correct path for this graph: 0->2->3->4, cost = 9+11+6 = 26
}

Real-World Use Case: Service Dependency Resolution
#

Consider a microservice platform where services declare dependencies on other services. During deployment, you want to find the minimum-latency path through the dependency graph to determine the optimal startup order and bottlenecks. The graph nodes are services, and edge weights represent measured inter-service call latency in milliseconds.

deps/resolver.go
package deps

import "graph"

// ServiceGraph models service-to-service dependencies with latency weights.
type ServiceGraph struct {
    g         *graph.Graph
    nameToID  map[string]int
    idToName  []string
}

func NewServiceGraph(services []string) *ServiceGraph {
    n := len(services)
    sg := &ServiceGraph{
        g:        graph.NewGraph(n),
        nameToID: make(map[string]int, n),
        idToName: make([]string, n),
    }
    for i, name := range services {
        sg.nameToID[name] = i
        sg.idToName[i] = name
    }
    return sg
}

func (sg *ServiceGraph) AddDependency(from, to string, latencyMs int) {
    sg.g.AddEdge(sg.nameToID[from], sg.nameToID[to], latencyMs)
}

// CriticalPath returns the highest-latency path from entry to target service.
func (sg *ServiceGraph) CriticalPath(from, to string) ([]string, int) {
    start := sg.nameToID[from]
    end := sg.nameToID[to]
    dist, prev := graph.DijkstraHeap(sg.g, start)

    nodeIDs := graph.ReconstructPath(prev, start, end)
    names := make([]string, len(nodeIDs))
    for i, id := range nodeIDs {
        names[i] = sg.idToName[id]
    }
    return names, dist[end]
}

Sample Graph and Shortest Path
#

graph LR
    A["A (0)"] -->|7| B["B (1)"]
    A -->|9| C["C (2)"]
    A -->|14| F["F (5)"]
    B -->|10| C
    B -->|15| D["D (3)"]
    C -->|11| D
    C -->|2| F
    D -->|6| E["E (4)"]
    E -->|9| F

    style A fill:#4CAF50,color:#fff
    style C fill:#2196F3,color:#fff
    style F fill:#2196F3,color:#fff
    style E fill:#FF5722,color:#fff

Shortest path from A to E: A(0) -> C(2) -> F(5) -> … wait, the edges do not connect F to E in this direction. The shortest path is A -> C -> D -> E with cost 9+11+6 = 26. The highlighted path shows the optimal route through nodes C and D.

Algorithm Comparison
#

Best for: graphs with non-negative edge weights, especially sparse graphs.

Time complexity: O((E+V) log V) with binary heap.

Space complexity: O(V+E).

Limitations: fails with negative edge weights (use Bellman-Ford). No ability to bias search toward the goal (use A* if a good heuristic is available).

Go implementation: container/heap provides all necessary primitives. No external dependencies required.

Best for: graphs with negative edge weights, detecting negative cycles.

Time complexity: O(V * E). Much slower than Dijkstra on large graphs.

Space complexity: O(V).

Key property: can detect negative-weight cycles (Dijkstra diverges on them). Required for financial arbitrage detection and certain network routing protocols (BGP path analysis).

When to use it in Go: currency exchange rate graphs where negative log-weights encode arbitrage opportunities. Rarely needed in typical platform engineering.

Best for: pathfinding when a heuristic estimate of the distance to the goal is available (geographic coordinates, Manhattan distance in a grid).

Time complexity: O(E) in the best case with a perfect heuristic; degrades to Dijkstra with a zero heuristic.

Key property: the heuristic must be admissible (never overestimates true distance). An inadmissible heuristic produces suboptimal paths.

When to use it in Go: spatial pathfinding (game maps, physical network topology with geo-coordinates). Not useful for dependency graphs where no heuristic distance is computable.

Limitations
#

Negative edge weights. Dijkstra assumes that once a node is finalized, no shorter path to it exists. A negative-weight edge can create a shorter path to an already-finalized node, which Dijkstra ignores, producing wrong results. Use Bellman-Ford or Johnson’s algorithm for graphs with negative weights.

Disconnected graphs. Nodes unreachable from the start retain their initial Infinity distance. Always check dist[target] == graph.Infinity before using the result. ReconstructPath returns nil for unreachable nodes, which the caller must handle.

Integer overflow. Using int(^uint(0) >> 1) as infinity works until you add two such values together (Infinity + weight overflows to a negative number). Guard additions: if dist[u] != Infinity && dist[u]+e.Weight < dist[v].

**Using a map instead of a slice for distances.** Maps have higher constant overhead than slices. If your nodes are integers in [0, N), always use `[]int`. Maps are only appropriate when node identifiers are sparse strings or non-contiguous integers. **Missing the stale-entry check in the heap version.** Without `if item.dist > dist[u] { continue }`, you process nodes multiple times. On a graph with many edges, this can double or triple the actual work and produce incorrect predecessor chains. **Not handling disconnected graphs.** Breaking when `dist[u] == Infinity` is necessary but not sufficient. A caller that dereferences `path[0]` on a nil path from `ReconstructPath` will panic. Always check for nil before using the path. **Forgetting to initialize `prev` to -1.** The zero value of `int` in Go is 0, not -1. A `prev` array initialized to zero will incorrectly suggest that node 0 is a predecessor of every node. Explicitly initialize to -1 as shown above. **Bidirectional graph with only one AddEdge call.** An undirected graph requires `AddEdge(from, to, w)` and `AddEdge(to, from, w)`. Missing the second call produces a directed graph that silently produces wrong shortest paths.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related