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.

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:
- 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.
- 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.
- 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:
| Cause | What happens | Fix |
|---|---|---|
| Changing ghc-options often | Every local package rebuilds from scratch | Keep dev flags stable in cabal.project.local |
| Running cabal clean out of habit | All local build products deleted | Only clean when you suspect a real corruption |
| Different GHC versions across the team | Store entries never shared | Pin the compiler version with ghcup and a with-compiler line |
| CI with no cache step | Every run rebuilds all dependencies | Cache 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.
More in Developer Tools
Developer Tools
Choosing a Haskell Code Formatter for Your Team
Your team keeps arguing about where the arrows go in a do block, and every pull request carries twenty lines of whitespace...
Developer Tools
Haskell Language Server Troubleshooting for Real Projects
You open a module, the editor shows a spinner for forty seconds, and then every import is underlined in red even though cabal...
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...