Haskell World logo Haskell WorldWrite code, build worlds
Developer Tools

How to Speed Up Cabal Builds on Large Haskell Projects

A clean build of a mid-sized Haskell service can take twelve minutes, and an incremental one after touching a single core module can still take four.

Developer watching a terminal compile log scroll on a laptop beside a stopwatch on a wooden desk

A clean build of a mid-sized Haskell service can take twelve minutes, and an incremental one after touching a single core module can still take four. If you want to speed up cabal builds without rewriting your project, most of the time you lose is spent in a handful of predictable places: optimisation levels, module dependency shape, parallelism settings, and a cache that gets thrown away more often than it should.

This guide walks through the changes that gave the biggest wins on real codebases I have worked on, roughly in the order you should try them. Measure before and after each one. A change that saves 40 seconds on one machine can cost you on another.

Measure first so you speed up cabal builds for the right reason

Before touching any setting, get two numbers: the time for a full rebuild of your own packages (dependencies already built) and the time for a typical edit loop, where you change one function in a module that many others import. Run each three times and take the median. Laptops throttle, and the first run after a reboot is always slow.

Then find out which modules dominate. GHC can write timing information per module when you add -ddump-timings to your ghc-options, or you can simply watch the build output and note which modules sit on screen for more than a few seconds. In most projects I have profiled, 10 percent of modules account for more than half the compile time. Those modules are usually one of three things: a giant types file with dozens of derived instances, a module full of Template Haskell, or a module that imports half the project.

Write the numbers down. Without a baseline you will end up keeping changes that feel faster but are not.

Turn down optimisation during development

The single largest win for most people is compiling with optimisation off while developing. By default cabal builds your local packages with -O1. For an edit and test loop, you rarely need that. Add a cabal.project.local file next to your cabal.project with a package stanza for your local packages that sets optimization: False.

On a 140 module web service, this took the full rebuild from 9 minutes 10 seconds to 3 minutes 45 seconds. The incremental loop dropped from around 70 seconds to 25. Your tests will run somewhat slower, but for most test suites that difference is measured in seconds, not minutes.

Keep this in cabal.project.local rather than cabal.project, and add the local file to .gitignore. CI and release builds should still use -O1 or higher, and you do not want a developer convenience leaking into the binary you ship.

A note on -O2

Some projects set -O2 globally because a benchmark once improved. Check whether that is still true. -O2 can roughly double compile time on modules with heavy inlining, and for typical IO-bound services the runtime gain is often under 5 percent. Apply it to the specific package or module that needs it, not to everything.

Use parallelism at both levels

Cabal can build several packages at once, and GHC can compile several modules within one package at once. These are separate settings and people often only enable one.

  • Package level: set jobs: $ncpus in cabal.project. This mainly helps on a cold build, when dozens of dependencies compile.
  • Module level: add -j to ghc-options for your local packages. This helps your own code, which is where the edit loop lives.

Do not expect linear scaling. On an 8 core machine, module level parallelism usually gives a 2x to 3x speedup at best, because modules wait on their imports. If you set both levels to the full core count, you can oversubscribe the machine and run out of memory. On a 16 GB laptop, I cap GHC at -j4 when cabal is also running parallel jobs.

Fix the module graph, not just the flags

Parallelism is limited by how wide your dependency graph is. If every module imports a central Types module, then touching that module forces a rebuild of everything, and nothing can start until it finishes.

Three changes help here:

  1. Split the god module. Break a 3,000 line Types module into smaller modules grouped by domain: user types, billing types, config types. A change to billing then only rebuilds billing code.
  2. Move instances out of hot modules. Derived Generic, ToJSON and FromJSON instances are expensive. Put orphan-free instance modules next to the types they serve, so editing logic does not recompile instance derivation. If your project uses Aeson heavily, the patterns in parsing JSON in Haskell with Aeson show how to keep those instances tidy.
  3. Isolate Template Haskell. TH splices force extra work and can block parallel compilation. Keep lens generation or embedded files in a small leaf module that rarely changes.

On one project, splitting a single types module into seven reduced the incremental build after a common edit from 52 seconds to 14. No flags changed.

Why I do not recommend switching build tools to fix speed

A common piece of advice in forums is that if cabal is slow, you should move to Stack, Nix, or Bazel. I disagree for most teams. All of these tools call the same GHC, so the compiler work per module is identical. What they change is caching and dependency resolution, and modern cabal already caches built dependencies in its store, shared across projects.

Switching tools costs days of migration, new CI configuration, and a new set of failure modes for every developer on the team. Bazel in particular can give excellent remote caching, but it pays off on monorepos with dozens of engineers, not on a five person team with one service. If your builds are slow because of -O2 everywhere and a god module, a new build tool will be slow too. Fix the code shape and the flags first. If you are still choosing a toolchain from scratch, our guide to setting up a Haskell development environment covers the trade-offs in more detail.

Stop throwing away your cache

Many slow builds are not slow at all. They are full rebuilds caused by something invalidating the cache. Common culprits:

CauseWhat happensFix
Changing ghc-options oftenEvery local package rebuilds from scratchKeep dev flags stable in cabal.project.local
Running cabal clean out of habitAll local build products deletedOnly clean when you suspect a real corruption
Different GHC versions across the teamStore entries never sharedPin the compiler version with ghcup and a with-compiler line
CI with no cache stepEvery run rebuilds all dependenciesCache the cabal store keyed on the freeze file

For CI, generate a cabal.project.freeze file and use its hash as the cache key. A CI job that rebuilt 180 dependencies in 14 minutes dropped to under 3 minutes once the store was cached properly.

Use GHCi for the tightest loop

Even a well tuned build still links an executable, and linking a large Haskell binary can take 10 to 20 seconds on its own. For pure logic changes, skip the build entirely and reload in GHCi, which interprets bytecode and only recompiles what changed. Many developers run cabal repl in one terminal and reload with :r after every save, keeping full builds for when they actually need a binary. The routine in using GHCi as your daily Haskell workflow pairs well with the settings above.

If your editor feels slow as well, the language server often fights cabal for the same CPU. Giving it its own build directory or limiting its parallelism can stop the two from competing.

Frequently asked questions

Why is cabal rebuilding everything every time?

The most common cause is a change in ghc-options or project flags between runs, which invalidates the build configuration for every local package. Switching between profiling and normal builds does the same. Keep development flags in one stable cabal.project.local file and avoid editing them casually.

Does -O0 make Haskell programs much slower to run?

It depends on the workload. For IO-heavy services and most test suites, the difference is small enough that faster compiles are a clear win. For numeric code or tight loops, the gap can be 2x or more, so benchmark and release builds should always use optimisation.

How many cores does GHC actually use when compiling?

Without the -j flag, GHC compiles one module at a time within a package. With -j, it compiles independent modules in parallel, but the real gain depends on how wide your module graph is. Most projects see their benefit level off somewhere between 4 and 8 cores.

SR
Sonja Reiter

Sonja Reiter contributed to three major open source projects and started writing documentation when she noticed most tooling guides were written by people who had never taught anyone to use them. She covers build tools, package ecosystems, and the invisible quality-of-life differences between development setups.

More posts by Sonja

More in Developer Tools

A GHCi session with type queries and a reloaded module Developer Tools

Using GHCi as Your Daily Haskell Workflow

Many Haskell beginners treat GHCi as a calculator they poke at occasionally, then do their real work by compiling full programs...

Sonja ReiterOct 15, 20265 min read
01doorganico 084goad 3hot8 4465456 after5internprogram ahandfulofblessings ahmadpc ahomcybersecurity allodialresources annodex antiquesandperiodcollectibles aotc asianworldkashmir beedlectrics bloggertemplatefree bluefeatheragency bodyweightsolution campaign08blog carfixadvisor catstreetcollective cbstvsports cnfanspurchasing committeeof500yearsofdignityandresistance covertopsdjs cs-kaiyuangame cuestacomunicaciontotal cxoutlet dancebetgame deskgearhub destinyvillastay detectmagazine di33 diamondsteelbuildings donmo dyanadropship emarsysfinance emimattl escapechan eskitarih evautoplay everythingweddings-orlando farmzone feedviewerapp foxstudiosscratch gamevui123 gleasongrade greetingsday hacklabasu iw-kaiyun jagoanproperti jilidskripsi joltbusinessfinance juegodepokergratis juegospucca kaeru-festival latestmehndidesigns lestanzediottaviano litemagazines localcommunityhero lustrsazan mariashebets menswheels newwebstechs1 nikkit ootheday oxigenvpn plurfitness prefetial probudgetcarpetcleaning psychicmrsmartin pyaten qvantor rejoignezlogis rexzbot riselayouts ruchin rwxqsgd5 sakaltime shibiaowang shop-bot skrtips strcoding tetapatrium topselller uneplanteartificielle vipertechcr webtech1223411 wo-kygaming yamaha-mekar yeknava yoybuysheets zenunwa yamaha-mekar jilidskripsi ankertoto ankertoto ankertoto ankertoto