Haskell in production standards
Criteria verified as of August 2026. Re-verify on the web before committing to anything (§8).
1. Scope and triggers
Applies to all Haskell work: .hs/.lhs, .cabal files, cabal.project and cabal.project.freeze, stack.yaml/stack.yaml.lock, package.yaml (hpack), hie.yaml, fourmolu.yaml, .hlint.yaml, GHCup pins and Stackage resolvers, and the pipelines that build/test Haskell. Covers backend services, CLIs, compilers/DSLs and libraries published on Hackage.
The axis of this skill: adopting Haskell in production is a team decision before it is a technical one. The language is not the risk — the risk is the bus factor, the learning curve, the hiring market and the compile times. A Haskell service written well by one person who leaves is a liability, not an asset. Before writing the first line you must be able to answer: are there ≥3 people who can operate and modify this?, does the team accept a 10-30 min CI on a clean build?, is there a training budget? If the answer is no, the right decision is another language (§7). Where Haskell genuinely wins: compilers/parsers/DSLs, business logic with dense invariants, correct-by-construction data transformation, and teams that already master it. Where it does not win: CRUD without invariants, infrastructure glue, code that will rotate through many hands.
Not applicable: see ocaml-fsharp-standards (the strict MLs — the boundary is laziness versus strict evaluation and the class of type system: type classes + higher-kinded types + monadic IO here, modules/functors and effects via handlers there; not "both are functional"), scala-standards (the other functional language with type classes: the boundary is the JVM runtime and Java interop as a design requirement, plus its Typelevel/ZIO ecosystem choice; cats-effect/fs2 are theirs even if the style looks similar), rust-standards (shares ADTs and ML lineage: the boundary is the memory model —ownership/no GC versus GC + laziness— and the purpose: systems and bounded latency versus correctness of the logic), jvm-spring-standards (only if the problem comes in through JVM/Spring), iac-standards (Nix as an infrastructure/deployment manager is theirs; Nix as this Haskell project's reproducible toolchain —flake.nix, haskell.nix, development shells— belongs to this skill), cicd-standards (the pipeline that runs the gates and its cache), kubernetes-standards (OCI image and deployment of the binary), api-design-standards (the HTTP/gRPC contract — here only its implementation with servant/wai), appsec-standards (threat modelling and agnostic vulnerability classes; here only the concrete Haskell sinks), vulnerability-management-standards (CVE triage and SLA; here only cabal audit/advisory-db in the gate), secrets-management-standards (where secrets live; here only not leaking them through Show), observability-standards (OTel pipeline and SLOs; here only the instrumentation and the RTS metrics), sql-standards (the SQL generated by persistent/esqueleto/hasql), python-standards/go-standards/typescript-standards (the real alternative when §7 says Haskell is not the fit).
2. Default toolchain
Verify the latest version on the web before pinning it in a real project (§8). What follows is the state verified as of Aug 2026.
| Piece | Choice | Verified state (Aug 2026) | Criteria |
|---|---|---|---|
| Installer | GHCup | the only supported one; Haskell Platform dead for years | Never the distro package manager's GHC |
| Compiler | GHC 9.14.x (first LTS release) | 9.14.1 published 2025-12-19; 9.14.2-rc1 in progress | LTS ⇒ ≥2 years of bugfixes; not the newest, the LTS |
| Live GHCs | 9.14 (LTS), 9.12.4, 9.10.3, 9.8.4, 9.6.7 | 9.4 and earlier EOL | ≤9.4 in production = no patches |
| Edition | GHC2024 declared explicitly |
introduced in GHC 9.10.1; the default is still GHC2021 |
The default can change: always pin the edition |
| Build | cabal-install 3.18.x | 3.18.1.0 (2026-07-29) | Sober default: GHCup + cabal |
| Alt. build | Stack 3.11.x | 3.11.1 (2026-06-15) — active, not abandoned | Only if you want the curated set as the build model |
| Dependency set | Stackage LTS as the compatibility oracle | LTS 24.x ⇒ ghc-9.10.3; Nightly 2026-08-01 ⇒ ghc-9.12.4 | See the GHC-LTS vs Stackage-LTS discrepancy below |
| IDE | HLS (haskell-language-server) | 2.14.0.0 (2026-04-27) | Installed by GHCup, version tied to the GHC |
| Formatter | fourmolu | 0.20.0.0 (2026-06-18), BSD-3-Clause | ormolu if you want zero configuration; only one per repo |
| Linter | hlint | 3.10 (2025-02-02), BSD-3-Clause | Low cadence: useful but verify before pinning it as a hard gate |
| Effects | ReaderT Env IO |
— | effectful 2.6.1.0 (2025-08-30, BSD-3) if the default is not enough |
| Tests | hspec or tasty + QuickCheck/Hedgehog | verify versions (§8) | Property-based is not optional (§4) |
Declared discrepancy (important). There are two notions of "LTS" that do not coincide: the GHC LTS (9.14, since Dec 2025) and the Stackage LTS (series 24, built on ghc-9.10.3 as of Aug 2026). Adopting GHC 9.14 today means leaving Stackage's curated set and resolving dependencies with cabal's solver + your own freeze. Default decision: the GHC is pinned by the availability of a curated snapshot that builds your dependencies, not by the highest number. Document the choice in an ADR and revisit it when Stackage promotes an LTS on 9.14.
GHC upgrade policy. LTS-to-LTS. The non-LTS releases come out ~every 6 months with a short life; the overlap between consecutive LTSs is ~6 months, and that is the real migration window. Adopting a .1 in production is unnecessary: wait for .2/.3. Each jump is tested first on a CI branch with the full matrix (-Wall -Werror included) because -Wall gains new warnings between versions — verified example: -Wincomplete-record-selectors entered -Wall in GHC 9.14, and libraries with -Werror break because of it.
GHCup / Stack / Cabal / Nix — real criteria, not a catalogue.
- GHCup + cabal: the default. Fewer pieces, it is what the rest of the ecosystem and HLS assume.
- Stack: choose it only if the value is the curated set as the build model (large team, many repos, a desire for "one resolver and done"). Still active (3.11.1, Jun 2026) — it is not a legacy decision. Do not mix the two in the same repo.
- Nix (
flake.nix+haskell.nixornixpkgs): only when the requirement is bit-reproducibility or non-trivial non-Haskell system dependencies (libpq, ICU, ffmpeg, cross-compilation). Nix doubles the tooling surface the team must be able to debug; it comes in with a designated owner or it does not come in. If it comes in, it comes in for everyone: none of this "some with Nix and others with cabal" — theshell.nix/devShell is the canonical environment and CI uses the same one.
Stackage versus Hackage — how a reproducible set is pinned.
- Hackage is the registry (uncurated, anyone publishes). Stackage is a set of versions verified to build together against a specific GHC; the snapshots are immutable once published.
- With Stack:
resolver: lts-XX.YY+ a versionedstack.yaml.lock. Extra-deps from Hackage with a hash, and each one is debt to justify. - With cabal:
cabal.projectcan import the Stackage snapshot asimport: https://www.stackage.org/lts-XX.YY/cabal.configand in additioncabal.project.freezeis versioned (cabal freeze). Without a freeze there is no reproducible build: the solver will resolve differently tomorrow. - Rule: the lockfile/freeze is always versioned, in libraries too (for their CI; the library publishes ranges, not pins).
- A dependency outside the snapshot ⇒ an explicit decision: look at maintenance, licence and transitive tree. No
git:without a pinned--sha256/rev.
3. Structure and conventions
- Layout:
src/(library),app/(thin executable),test/,bench/. All the logic lives in the library; the executable only parses the CLI, builds theEnvand callsmain. This is what makes the code testable and reusable, and what avoids the 2000-lineMain.hs. - Multi-package as soon as there are two real boundaries:
cabal.projectwithpackages: ./pkg-*.commonstanzas in the.cabalto avoid repeatingghc-options/default-extensions. - Modules by domain, not by technical type (
Billing.Invoice, notTypes/Utils). Explicit export lists in every module: the API is a decision, not an accident. - Types first: newtypes over primitives (
newtype UserId = UserId UUID), smart constructors that validate at the boundary, impossible states unrepresentable with ADTs.NonEmptyinstead of "a list that is never empty". The type system is the first layer of tests. - Record fields strict by default (
!Int) in domain data types;StrictDataper module when the type is a data container. deriving stock/newtype/anyclassalways explicit (DerivingStrategiesalready comes inGHC2024): implicit deriving is ambiguous when reading.
Language extensions
- Declare
default-language: GHC2024in the.cabal. Verified:GHC2024was introduced in GHC 9.10.1 and adds on top ofGHC2021:DataKinds,DerivingStrategies,DisambiguateRecordFields,ExplicitNamespaces,GADTs,MonoLocalBinds,LambdaCase,RoleAnnotations. GHC's default with nothing declared is stillGHC2021(for compatibility), which is why it is declared. - If the project is tied to GHC <9.10:
GHC2021+ minimaldefault-extensions. - Commonly sane additions per project:
OverloadedStrings,StrictData,DerivingVia,TypeFamilies(if the design calls for it). - Banned for maintenance cost (require an ADR and an owner):
UndecidableInstances,IncoherentInstances,OverlappingInstances/{-# OVERLAPPING #-}in bulk,AllowAmbiguousTypesas a patch over a confused design,ImpredicativeTypes,TemplateHaskellbeyond what already requires it (aeson/persistent/lens) — TH costs compile time, breaks cross-compilation and is opaque when debugging.CPPonly for version compatibility bands, never for logic. unsafeCoerceandGeneralizedNewtypeDerivingover classes with dangerous methods: FORBIDDEN without written justification (§7).
String / Text / ByteString
String = [Char]is a performance bug by default: a linked list of boxed characters, one cons-cell per character. It is not a style choice.Text(text, UTF-8 since text-2.0) for all human text.ByteStringfor bytes (I/O, network, binary, files).Stringonly at the boundary with old APIs (FilePath,Show, some inbase) and converted immediately.OverloadedStringsenabled; literals without scatteredpack.- Lazy versus strict:
Data.Text/Data.ByteStringstrict by default; the.Lazyvariants only for streaming large data that does not fit in memory, and then with explicit reasoning. - Decoding bytes to text always with explicit error handling (
decodeUtf8'/decodeUtf8With), neverdecodeUtf8over untrusted input: it throws an exception impossible to catch in pure code. - File paths:
OsPath/OsStringwhen the project touches non-ASCII or binary paths;FilePath = Stringis the classic trap.
Laziness and space leaks — a first-class bug category
Laziness is the language's distinctive feature and the primary source of production incidents: an accumulated thunk does not fail in tests, it fails 6 hours in with a full heap.
foldlis banned. Usefoldl'. Verified:foldl'is exported fromPreludestarting with base-4.20 (GHC 9.10) (CLC #167) — before that you must import it fromData.List/Data.Foldable. On GHC <9.10 the explicit import is mandatory; none of this "it just wasn't at hand".- Strict accumulators:
BangPatterns(go !acc x = ...),seq/force(deepseq) where the accumulator is structured. - Structures:
Data.Map.StrictandData.IntMap.Strictby default — the lazy variant only with a reason.modifyIORef'/atomicModifyIORef', never the lazy versions.foldl'over aMap, notfoldraccumulating. - Record fields that accumulate state: strict (
!) orStrictData. LazyStateis a leak generator: useControl.Monad.State.Strict. - Heap profiling as a routine tool, not an emergency one: build with
-prof -fprof-auto, run with+RTS -hc -hy -hb -l-au, visualise witheventlog2html/hp2ps;ghc-debugto inspect the heap of a live process. Every long-lived service is profiled before going to production, not after the first OOM. - Operational metric: RTS
live byteswith a sustained upward trend = a leak until proven otherwise (§6). -Walldoes not detect space leaks. There is no linter that replaces profiling.
Alternative Prelude
- Default: the standard
Prelude. A customPreludeis an entry barrier for every new person and a source of friction with examples and libraries. relude: eligible in a new greenfield project where the team agrees — it removes the partial functions, usesTextby default, bringsNonEmpty. It is declared withmixins/NoImplicitPreludeuniformly across the whole repo.rio: eligible if its architecture (RIO env, logging, resource handling) is adopted as a whole as well. Adoptingriojust for the Prelude is paying the cost without the benefit.- Hard rule: zero or one. Never two different preludes in the same tree, nor a homemade
MyProject.Preludemodule that grows without an owner.
Error handling
- Two distinct axes that must not be mixed: expected domain failures versus exceptional/infrastructure failures.
- Domain ⇒ types:
Either MyError a,ExceptT MyErrorin the layer that needs it, an error ADT per operation. The caller pattern matches and the compiler checks exhaustiveness. - Infrastructure (I/O, network, disk, timeouts,
bracket) ⇒IOexceptions, which in Haskell are unavoidable (async exceptions included). Catch withsafe-exceptions(orControl.Exceptionwith judgement):bracket/finallyfor every resource, nevercatchoverSomeExceptionthat also swallows the asynchronous ones (ThreadKilled, timeouts). - FORBIDDEN in production code:
error,head,tail,init,last,fromJust,read,(!!),maximum/minimumover possibly empty lists,undefined. Alternatives: pattern matching,uncons,listToMaybe,NonEmpty,lookup,readMaybe. - Verified state of
base:headandtailfromData.Listcarry a{-# WARNING #-}with categoryx-partialsince base-4.19 (GHC 9.8), code[GHC-63394]. Nuances you must know: (a) it is a warning, not a deprecation — the CLC explicitly left it out of deprecating/removing; (b)initandlastare NOT marked, so the warning does not cover the whole class; (c) it is silenced with-Wno-x-partialand there is pressure in GHC to take it out of-Wdefault. Operational conclusion: do not delegate the prohibition to the compiler. The real gate ishlint+ review +-Werror, and-Wno-x-partialis forbidden in this catalogue. - An error is never silenced by turning it into a default value (
fromMaybe 0over a real failure). The error is propagated with context or decided explicitly.
Effect architecture — criteria, not a catalogue
Decision order, from less to more machinery. Do not go up a level without a concrete problem the previous level does not solve.
ReaderT Env IO(theReaderTpattern overIO) — the sober default. AnEnvrecord with the capabilities (DB connection, logger, HTTP client, config) injected throughReaderT. Testable by swapping theEnv. Predictable performance, readable compiler errors, anyone understands it in an afternoon. The vast majority of services need nothing more.mtl(MonadReader/MonadState/MonadErroras constraints): useful for polymorphic functions in the domain layer. Cost: the n² instance problem when adding your own transformers, and error messages that degrade quickly. Acceptable in small doses on top of pattern (1).- An effect system (
effectfulas this category's default option — 2.6.1.0, Aug 2025, BSD-3-Clause;cleff,fused-effectsas alternatives): only when there are several orthogonal effects that need to be interpreted in more than one way (real / mock / dry-run / instrumented) and that already hurts. Real cost: a structural dependency across all the code, one more curve for every new person, and a fragmented ecosystem.effectfulis chosen for performance (IO+ReaderTunderneath) and for tolerable type errors;polysemyis outside the default for its performance and inference cost unless proven otherwise today (§8).
- Decide on one and document it in an ADR. A repo with
ReaderTin one module,mtlin another andeffectfulin a third is the worst possible outcome. - Cross-cutting rule: the domain layer is pure and effect-free; effects live at the boundary. That is what delivers the value, not the framework.
Concurrency
- GHC's lightweight threads (
forkIO): cheap, thousands of them are normal. There is no pool to manage. - No bare
forkIO: every thread has an owner.async(withAsync,concurrently,race,mapConcurrently) orkifor structured concurrency; the parent thread observes the child's exception. AforkIOwhose error nobody sees is a silent loss of work. - STM by default for shared state:
TVar/TQueue/TBQueuecompose;MVaronly for simple mutual exclusion or explicit empty-full;IORefonly for state without contention (and withatomicModifyIORef'). I/O insideatomicallyis forbidden (the type already prevents it — do not circumvent it withunsafePerformIO). - Bounded queues (
TBQueue, notTQueue) by default: explicit backpressure. timeouton every network operation; asynchronous exceptions respected:bracket/bracketOnErrorto release resources even ifThreadKilledarrives;uninterruptibleMaskonly in the minimum documented gap.- Watch out for thunks shared between threads: a lazy
TVaraccumulates the work until someone forces it —modifyTVar', always the primed one.
4. Quality and testing
Formatting and lint
- fourmolu (0.20.0.0, BSD-3-Clause) with a versioned
fourmolu.yaml, or ormolu if zero configuration is preferred. Only one per repo; style is not debated, the tool decides it. - hlint with a versioned
.hlint.yaml. Verified: latest release 3.10 (Feb 2025) — low cadence; it is still the de facto standard but verify its state before turning it into a blocking gate (§8). Custom rules to forbid the partial functions thatbasedoes not mark (init,last,fromJust,(!!),read) — the linter covers the compiler's gap. - HLS on every workstation, with the version tied to the project's GHC via GHCup.
Warnings: -Wall -Werror and which ones
- Base
ghc-optionsin thecommonstanza:
common warnings
ghc-options:
-Wall
-Wcompat
-Widentities
-Wincomplete-record-updates
-Wincomplete-uni-patterns
-Wmissing-export-lists
-Wmissing-home-modules
-Wpartial-fields
-Wredundant-constraints
-Wunused-packages
-Werrorin CI, never in the.cabalof a published library (it breaks third-party builds with a newer GHC). In CI it is passed by flag:cabal build --ghc-options=-Werror.-Wincomplete-patterns(inside-Wall) is the most valuable gate in the language: a non-exhaustive match is a runtimeerror. As an error, no exceptions.- Verified warning:
-Wallchanges content between GHC versions (-Wincomplete-record-selectorsentered-Wallin 9.14). Every GHC upgrade is done on a branch with-Werroron and the new items are triaged; never-Wno-*in bulk "to make it build". {-# OPTIONS_GHC -Wno-... #-}always at file level, with a specific lint and a reason comment.-Wno-x-partialis FORBIDDEN (§3).
Testing
- Framework: hspec (BDD, good output) or tasty (aggregates multiple suite types). One per repo.
- Property-based testing is Haskell's differential value and it is not optional in code with invariants. QuickCheck (random generation, shrinking by type) or Hedgehog (integrated generators with shrinking, better by default for properties with preconditions). Mandatory properties where they apply: serialisation round-trip (
decode . encode == idfor JSON/binary/DB), algebraic laws of your own instances (Functor,Monoid,Ord— withquickcheck-classesor equivalent), idempotence, invariants of your own data structures. - State modelling (
quickcheck-state-machine,hedgehogstate machines) for concurrent or stateful logic: it is the only practical way to find races. - Conventional unit tests for the happy path and the edges and errors: empty lists, numeric limits, invalid decoding, timeouts, cancellation.
- Golden tests (
tasty-golden) for large, stable outputs (renders, generated SQL, specs). - Integration with real dependencies (
testcontainers-hsor Docker Compose in CI) with the same engine version as production; mock your own boundaries, not the world. doctestin published libraries: the Haddock examples are verified.- Every bugfix leaves a regression test that fails before the fix. A flaky test: it is fixed or it is deleted.
CI gates (they break the build, in order of cost)
fourmolu --mode check $(git ls-files '*.hs')
hlint .
cabal build all --ghc-options=-Werror # -Wall -Werror + -Wunused-packages
cabal test all
cabal check # .cabal sanity (publishable packages)
cabal haddock all # the docs build
cabal audit / cabal-audit against security-advisories # verify name and state (§8)
Main always green. GHC matrix in CI: the production GHC as mandatory + the next one as allowed-to-fail (so the upgrade is not a big bang). Do not build against 5 versions "just because": each one is minutes of CI.
5. Stack security
unsafePerformIO— FORBIDDEN. It breaks referential transparency, and with it all the compiler's reasoning: the optimiser can duplicate, eliminate or reorder the effect. Single exception: FFI bindings encapsulated in a demonstrably pure API, with{-# NOINLINE #-}, a-- SAFETY:comment justifying the invariant and a dedicated test.unsafeDupablePerformIO,unsafeInterleaveIO,unsafeCoerceandaccursedUnutterablePerformIO: the same rule, hardened.Text.Read.readover untrusted input — FORBIDDEN.readthrows an exception impossible to handle in pure code and its parser is not designed as a trust boundary. UsereadMaybeand, for real formats, a parser (attoparsec,megaparsec) with size and depth limits.- Uncurated Hackage dependencies: Hackage reviews nothing. Every dependency outside the Stackage snapshot is a trust decision: look at the latest release, number of maintainers, licence (the real
LICENSEfile, not the.cabalfield) and transitive tree (cabal-plan). Remember thatSetup.hsand Template Haskell run arbitrary code at build time with the CI runner's permissions — every new dependency is supply chain surface, not a free import. - SCA: the Haskell Security Response Team maintains
security-advisories(advisory-db) and there is an audit tool; verify the exact name, the integration and its state before pinning it as a gate (§8). Run it on every PR and on a schedule as well. - Deserialisation: JSON with
aesonand concrete types + derivedFromJSON, never decoding toValueand navigating by hand. Payload size limits at the HTTP boundary (waimiddleware / servant) and nesting depth limits. No deserialisation that instantiates arbitrary types. - Parameterised SQL only:
persistent/esqueleto,hasql/rel8orpostgresql-simplewith placeholders. Building SQL by concatenatingTextis forbidden;rawSqlonly with parameters. - Secrets: never in
Show/Generic-derived. Wrap credentials in a newtype with a manualShowthat redacts, or use a secrets library; logs derive fromShowmore often than people think. No secrets in the.cabal, in the tree or in the eventlog. - Crypto:
crypton/cryptonite-successor andtls/crypton-connectionmaintained; verify maintenance state before pinning (§8). AES-GCM, ChaCha20-Poly1305, Argon2/bcrypt, TLS 1.2+. Security randomness from a CSPRNG (crypton'sgetRandomBytes), neverSystem.Random. - Containers: multi-stage build, linked and stripped binary (
-split-sections,strip), distroless image orscratchif it is static, non-root, read-only FS. Check that the dynamic C libraries (libgmp, libpq) are in the final image — the classic failure. - Long-lived services: close
-rtsoptsin the production binary or restrict the accepted flags.+RTSfrom an environment variable controlled by the attacker is arbitrary runtime configuration execution.
6. Performance and operability
- RTS flags: they are compiled in and justified, not copied. Build with
-threaded -rtsopts "-with-rtsopts=...". Starting points to measure, not to assume:-Nwith explicit capabilities matched to the container's CPU limit (-N4), not a bare-N: on K8s,getNumProcessorssees the physical machine and oversubscribes. This is Haskell's number one operational mistake in containers.-A(nursery) larger than the default to reduce minor GCs in services with heavy allocation (e.g.-A64m) — measure p99 latency before and after.-M(max heap) aligned with the pod's memory limit, so the process dies with a diagnosable heap error instead of by the kernel's OOM-kill.--nonmoving-gcas an option for bounded pause latency on large heaps: only with measurement, not by default.
- Observability: RTS metrics always exported (
GHC.Stats/getRTSStatswith-T), via a Prometheus exporter or EKG; verify the maintenance state of the specific library before pinning it (§8). Minimum series: live bytes, GC wall/cpu time and maximum pause, live threads, capabilities. The live bytes trend is the leak detector in production. - Structured logs (
katip,co-log, orrio's logger) in JSON; traces with OpenTelemetry when the project's ecosystem supports it (verify the binding's maturity — §8).putStrLn/print/traceare forbidden in service code;Debug.Tracedoes not get merged. - Timeouts and limits at every boundary: HTTP client (
http-clientwith an explicitresponseTimeout— the default is not enough), bounded DB pool,timeouton service calls, request size limit. No defined timeout = bug. - Graceful shutdown mandatory: a
SIGTERMhandler that stops accepting connections, drains in-flight ones with a deadline and closes resources withbracket.warpwithsetInstallShutdownHandler/setGracefulShutdownTimeout. Without this there is no reliable rolling deploy. - Separate health endpoints: trivial liveness, readiness that checks dependencies.
- Profile before optimising:
-prof -fprof-auto++RTS -pfor time,-h*for heap, eventlog +ghc-events/eventlog2htmlfor concurrency and GC.criterion/tasty-benchfor comparable microbenchmarks. Careful: the profiling build changes the generated code — confirm findings on the normal binary. - Optimisation:
-O2in production (-O0/-O1in development for build speed).INLINABLE/SPECIALIZEon polymorphic hot-path functions (GHC 9.14 improved specialisation considerably); list/vector/textfusion is real but breaks easily — verify with-ddump-simplbefore claiming it happens.
Compile times and CI — an operational risk, not an annoyance
Build times are Haskell's most underestimated recurring cost and the usual reason a team stops running the full CI.
- Explicit budget: clean build and incremental build measured and watched; if the incremental one exceeds ~2 min, it is an architectural bug of the project.
- Levers, in order: CI caching of the cabal/stack store and of
dist-newstyle(with a key by GHC + dependency plan); splitting into packages to parallelise and bound recompilation; removing unnecessaryTemplateHaskell(it invalidates the cache aggressively and blocks cross-compilation); dropping-O2outside release;-jmatched to the runner's cores;-fwrite-ide-infoonly where it is used. -Wunused-packagesenabled: dead dependencies that keep costing minutes.- Runners with enough RAM: GHC with
-O2and TH consumes gigabytes; a compiler OOM is misdiagnosed and suffered for weeks.
7. Long-term sustainability
This is the section that decides whether the project survives. The dominant risk in Haskell is not technical.
- Bus factor ≥3 as an entry requirement, not as an aspiration. With 1 person who masters the code, a Haskell service is a liability from the day that person rotates out. Before approving the stack: name in writing who else can deploy, debug a space leak and upgrade GHC.
- Hiring: the market is small and expensive, but high quality; hiring "good people who will learn Haskell" works better than looking for haskellers. Budget 3-6 months to full productivity for a senior with no prior typed FP experience. The curve is not in the syntax: it is in laziness,
IO/effects and in reading type errors from libraries with elaborate types. - Documentation as bus factor mitigation: Haddock on every public API, ADRs for the structural decisions (effects, Prelude, build tool, Nix yes/no) and a
CONTRIBUTINGthat starts from zero with GHCup. Haskell code is self-explanatory to someone who already knows Haskell — to nobody else. - Upgrade cadence: GHC LTS to GHC LTS, taking advantage of the ~6 month overlap. Point releases (
.2,.3) without delay. Dependencies with grouped Renovate/Dependabot; majors by hand with a changelog. Stackage snapshot: move up an LTS quarterly or at least every six months — leaving the snapshot frozen for two years turns the upgrade into a project. - Published libraries: PVP (not SemVer: in Haskell versioning is
A.B.C.DwithA.Bas major) andcabal checkin the gate. Version bands on dependencies,Cabal.project.freezeonly for your own CI. - Conscious debt: every shortcut with
-- TODO(user): reason — issue. No-Wno-*norhlint: ignorewithout a comment and a link.
PROHIBITIONS (require an ADR and approval to make an exception).
- ❌
error,undefined,head,tail,init,last,fromJust,read,(!!),maximum/minimumover lists — in production. Thebasewarnings cover onlyhead/tail: the gate is hlint + review. - ❌
unsafePerformIO,unsafeDupablePerformIO,unsafeInterleaveIO,unsafeCoerce,Obj-tricks via FFI — except for the documented exception in §5. - ❌
foldl(usefoldl'), lazyData.Mapand lazyControl.Monad.Stateout of inertia,modifyIORefwithout the prime, an unboundedTQueueas a work bus. - ❌
Stringas the text type in new code;decodeUtf8without error handling over untrusted input. - ❌
catch/handleoverSomeExceptionthat swallows asynchronous exceptions; resources withoutbracket;forkIOwithout an owner or observation of the result. - ❌
-Wno-x-partialand-Wno-*in bulk;-Werrorin the.cabalof a published library; CI without-Werror. - ❌ Mixing Stack and cabal in the same repo; lockfile/freeze outside the VCS;
git:without rev and hash; dependencies outside the snapshot without justification. - ❌ Two alternative Preludes in the same tree; a homemade
Preludewithout an owner. - ❌ More than one effect architecture in the same codebase; moving up to an effect system without a problem that
ReaderT Env IOdoes not solve. - ❌
UndecidableInstances/IncoherentInstances/ImpredicativeTypes/AllowAmbiguousTypeswithout an ADR; newTemplateHaskellwithout measuring its compile cost. - ❌
putStrLn/print/Debug.Tracein services; secrets reachable through derivedShow. - ❌
-Nwithout explicit capabilities in a container; a production binary with-rtsoptsopen; deploying a long-lived service without having profiled the heap. - ❌ GHC out of support (today: ≤9.4) in production; adopting a GHC
.1in production. - ❌ A single human capable of maintaining the service.
When NOT to choose Haskell (honest prohibition).
- ❌ When the team cannot sustain a bus factor ≥3 nor budget the curve. This alone rules the stack out.
- ❌ CRUD and glue without invariants the type captures: the return on investment does not appear, and the cost does.
- ❌ Hard latency or real-time requirements: there is a GC, and the pauses are not trivially boundable →
rust-standards. - ❌ Work that depends on an ecosystem where Haskell is weak: ML/data science (→
python-standards), web frontend and native mobile, SDK-centric cloud ecosystems (→go-standards/typescript-standards). - ❌ Environments with high staff turnover or delivery by interchangeable external vendors.
- ❌ "Because the team wants to learn Haskell" on a production service. Learning is fine; the vehicle is not a system with an SLA.
8. Mandatory web verification
Before pinning a version or flag, or claiming the state of the ecosystem, verify on the web (not from memory):
- Current GHC and LTS policy:
haskell.org/ghcanddiscourse.haskell.org(schedule/LTS announcement athaskell.org/ghc/blog/20250702-ghc-release-schedules.html); EOL summary atendoflife.date/ghc. Is 9.14 still the LTS? Has the next pre-announced LTS come out? Which versions have entered EOL? - What the language edition brings: the official users guide,
exts/control.htmlfor the specific version. Has the default changed fromGHC2021toGHC2024? Is there a newGHC20xx? - The target version's
-Wall: release notes for the specific GHC — the new warnings that enter-Wallbreak builds with-Werror. - State of the partial functions in
base:hackage.haskell.org/package/base/changelogand thehaskell/core-libraries-committeeissues. Isx-partialstill in-Wdefault(there is pressure to take it out, GHC #24322)? Haveinit/lastbeen marked? Has there been a real deprecation? - Stackage snapshot:
stackage.org/snapshots— the latest LTS, its GHC and whether an LTS on the GHC you want already exists. This is where the discrepancy declared in §2 is resolved. - Build tools: releases of
commercialhaskell/stackandhaskell/cabal(/releases.atomfeeds; the GitHub API may return 403 without auth). Check that Stack still has recent releases before repeating the myth that it is abandoned — as of Aug 2026 it does: 3.11.1, Jun 2026. - Quality:
hlint(latest verified release 3.10, Feb 2025 — check whether it is still alive before making it a blocking gate),fourmolu/ormolu, HLS and its matrix of supported GHCs (haskell-language-server.readthedocs.io/en/latest/support/ghc-version-support.html). - Advisories and auditing:
github.com/haskell/security-advisories— the exact name of the audit tool (integratedcabal auditversus externalcabal-audit), its state and how it is integrated into CI. Not verified in detail as of Aug 2026: gap. - Licences and maintenance of every library pinned as a default, reading the raw
LICENSE(raw.githubusercontent.com), not the.cabalfield. Precedents from the catalogue: tools that change licence (Trivy) or declare themselves feature complete with a commercial move (gitleaks v2). Verified as of Aug 2026: hlint BSD-3-Clause, fourmolu BSD-3-Clause, effectful BSD-3-Clause. Not verified: ormolu, relude, rio, aeson, servant, crypton, katip.
Declared gaps (not verified as of Aug 2026, do not fill from memory):
- Current version and
recommendedtag of GHCup (the tag is defined by its metadata; check withghcup list -t ghc). - Versions and maintenance state of hspec, tasty, QuickCheck, Hedgehog, aeson, servant, persistent/esqueleto, hasql, conduit, warp, http-client.
- State of EKG and of the RTS metrics exporters (several candidates, uneven maintenance) and maturity of the OpenTelemetry binding for Haskell.
- Current comparative state of
cleff,fused-effects,polysemy(the §3 judgement onpolysemyis historical and must be reconfirmed). - State of
crypton/tlsand oftestcontainers-hs. - Exact date of the next GHC LTS (the published plan pointed to 9.22 around 2028; it is a plan, not a commitment).
If the web contradicts this document, the web wins — flag the discrepancy.