Lean 4 best practices
Conventions for writing Lean 4 libraries in the Mathlib4 style. Distilled from the official Mathlib contributor docs plus painful lessons learned bootstrapping the Solanalib library — examples below use Solanalib's code, but the rules are general.
When this skill applies
- Adding or modifying any
.leanfile in a library that aims to follow Mathlib conventions. - Editing
lakefile.lean,lean-toolchain,lake-manifest.json, or.github/workflows/for a Lean project. - Reviewing a Lean PR for style or structure.
- Bootstrapping a new Lean 4 library that should match the Mathlib ecosystem's conventions.
Default decisions
Make these without asking:
- Verify locally before pushing.
elaninstalls the right toolchain from thelean-toolchainfile;lake build(andlake test) before any push. CI cycles cost minutes; local builds cost seconds. Never push a Lean change without a local build pass. - Track Mathlib's
lean-toolchainpin. When bumping Lean, fetchhttps://raw.githubusercontent.com/leanprover-community/mathlib4/master/lean-toolchain, setlean-toolchainto match, and update the Mathlibrequirerev inlakefile.leanto the same tag.doc-gen4'smainbranch tracks this — drift breaks docs. - Copyright authorship line is
<Project> Contributors. Never personal names, never the parent org. TheCopyright (c) YEAR <Owner>line is the legal-owner statement and is separate. - No AI-attribution trailers (
Co-Authored-By: Claude …) in commits. - Commit
lake-manifest.jsonbut not.lake/. Reproducible builds depend on the manifest;.lake/is per-machine cache.
Operating procedure
Adding a new module
- Decide the file path. Organise by domain concept, not by consumer or framework (Mathlib organises by mathematical concept, not by who uses it; sibling libraries should organise the same way within their domain).
- Write the file using the template in
## File templatebelow. - Add
import <Library>.<Concept>.<Aspect>to the library root file (alphabetical within its block). - Run
lake buildlocally. Fix any errors. Runlake test. - If the module introduces a load-bearing API surface, add a regression test under the test library mirror (
<Library>Test/<Concept>/<Aspect>Test.lean) and import it from the test root.
Adding a new theorem
- Place it in the file whose subject it concerns, not in a global
Theorems.lean. - Name it
<conclusion>_of_<hypothesis>(Mathlib pattern:add_pos_of_pos_of_nonneg) or<descriptive_name>if self-explanatory (Nat.sub_add_cancel). - Write the statement with all binders explicit (
autoImplicit := falseis on globally in the lakefile — keep it that way). - Prefer term-mode proofs (
:= rfl,:= by simp) over multi-linebyblocks when feasible. Otherwise use the tactic style in## Proof stylebelow. - Tag with
@[simp]only if the lemma rewrites toward a normal form. Don't tag the headline result.
Adding a new type
Default to notation over abbrev when the type is a synonym for Nat/Int. See ## Type aliases: notation, not abbrev below — this is the most-likely-to-bite gotcha.
For real structures, use structure/inductive with @[ext]:
@[ext]
structure Slot where
/-- The slot number on the cluster. -/
value : Nat
deriving Repr, DecidableEq
File template
Every .lean file starts with this exact shape:
/-
Copyright (c) <YEAR> <Legal Owner>. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Authors: <Project> Contributors
-/
import <Library>.<dep1>
import <Library>.<dep2> -- alphabetical within their block
/-!
# <Module title — short, capitalised>
<One-paragraph summary explaining what this module is for and why it exists.
End at the level of "what does this file give to the rest of the library".>
## Main definitions
* `<Library>.Foo` — <one-line gloss>
* `<Library>.Foo.bar` — <one-line gloss>
## Main statements
* `foo_property_baz` — <what it proves and why it matters>
-/
namespace <Library>
-- declarations
end <Library>
Critical ordering: copyright /- ... -/ comment first, then imports, then /-! ... -/ module docstring. The module docstring is a declaration, not a comment, so it cannot precede import. Mistaking this produces invalid 'import' command, it must be used in the beginning of the file.
Subsection headers (## Main definitions, ## Main statements, ## Implementation notes, ## References) follow Mathlib's convention and are rendered as anchored sections in the doc-gen4 output.
Naming conventions
Inherited from Mathlib:
| Kind | Convention | Example |
|---|---|---|
| Types, structures, classes, inductives | UpperCamelCase |
Account, TransferResult |
| Defs, functions, fields | lowerCamelCase |
credit, lamportsPerSol, source |
| Theorems, lemmas, propositions | snake_case |
transfer_preserves_total, credit_lamports |
| Files | UpperCamelCase.lean |
Transfer.lean |
| Folders | UpperCamelCase |
Account/, Primitives/ |
Theorem-name pattern: <conclusion>_of_<hypothesis>. For projection-shape @[simp] lemmas, the form <def>_<field> is conventional (credit_lamports, Account.mk_lamports).
Bounded types: bridging UInt64 to Nat for omega
omega in Lean 4.31.0-rc1 has very limited UInt64 support. Tests like (a b : UInt64) (h : a ≤ b) : b - a + a = b := by omega fail with No usable constraints found. The fix is to keep two parallel type-aliases and bridge between them with .toNat:
notation "Lamports" => UInt64 -- strict on-chain shape
notation "LamportsUnchecked" => Nat -- for omega-style reasoning
The pattern in practice:
-- Define operations on UInt64 with explicit bounds proofs in the signature:
def credit (a : Account) (amount : Lamports)
(h : a.lamports.toNat + amount.toNat < UInt64.size) : Account :=
{ a with lamports := a.lamports + amount }
-- Provide a `_toNat` bridge lemma that drops the result into Nat:
theorem credit_lamports_toNat (a : Account) (n : Lamports)
(h : a.lamports.toNat + n.toNat < UInt64.size) :
(credit a n h).lamports.toNat = a.lamports.toNat + n.toNat := by
rw [credit_lamports, UInt64.toNat_add, Nat.mod_eq_of_lt h]
Then conservation-style proofs become:
theorem transfer_preserves_total ... :
(transfer ...).source.lamports.toNat + (transfer ...).destination.lamports.toNat
= src.lamports.toNat + dst.lamports.toNat := by
have h_under_nat := UInt64.le_iff_toNat_le.mp h_under -- bridge UInt64 ≤ → Nat ≤
simp only [transfer]
rw [debit_lamports_toNat, credit_lamports_toNat]
omega -- now in Nat-land
Key core lemmas to know (all in Init.Data.UInt.Lemmas):
| Lemma | What it gives |
|---|---|
UInt64.le_iff_toNat_le |
a ≤ b ↔ a.toNat ≤ b.toNat (proved by rfl) |
UInt64.toNat_add (@[simp]) |
(a + b).toNat = (a.toNat + b.toNat) % UInt64.size |
UInt64.toNat_sub_of_le (@[simp]) |
b ≤ a → (a - b).toNat = a.toNat - b.toNat |
UInt64.toNat_lt (@[simp]) |
n.toNat < 2 ^ 64 |
Nat.mod_eq_of_lt |
n < m → n % m = n (used with toNat_add to drop the %) |
.toNat vs .toBitVec — which bridge?
There are two possible bridges out of UInt64. Use the one that matches what you're proving:
| Use | Bridge | Closer |
|---|---|---|
| Conservation / accounting / counting (Solanalib's core case) | .toNat |
omega |
| Wrap-aware arithmetic, ring algebra, explicit-overflow exploit proofs | .toBitVec |
bv_omega |
Mathlib's Mathlib/Data/UInt builds its UInt64 algebra on .toBitVec, not .toNat — but it's scoped (open scoped UInt64.CommRing) precisely because the Mathlib authors flag that algebraic instances on UInt64 "interfere more with software-verification use-cases."
For Solanalib, .toNat is the default because a conservation theorem stated at .toNat level needs fewer preconditions than the equivalent UInt64-level statement: the natural sum doesn't need a "RHS-doesn't-overflow" side condition, since Nat is unbounded. Same trade as every comparable software-verification project (seL4, CompCert, Cardano Plutus).
Don't pre-emptively import Mathlib.Data.UInt — it brings ~700 extra build jobs for the algebraic-instances chain we don't currently use. Import surgically when a specific theorem genuinely needs bv_omega or scoped ring tactics.
Why not abbrev?
The historical reason for using notation (over abbrev) was that omega does not unfold abbrev T : Type := Nat. Its preprocessor classifies hypotheses by their surface type and silently drops constraints over the alias, producing omega could not prove the goal: No usable constraints found.
Wrong:
abbrev Lamports : Type := Nat -- omega will refuse to see (a : Lamports) ≤ b
Right:
namespace Solanalib.Lamports
-- declarations using Nat directly (the namespace header is parsed BEFORE the
-- notation is declared, so `Lamports` here is still an identifier).
end Solanalib.Lamports
/-- A lamport count: the smallest unit of native Solana value. -/
notation "Lamports" => Nat
notation is a parse-time substitution, so (amount : Lamports) is elaborated as (amount : Nat) from the start. omega and simp-set lemmas about Nat work without ceremony. The notation must be declared after any namespace <SameName> blocks in the same file, because the parser substitutes the token everywhere once the notation is in scope.
Escape hatch: if/when strict typing is needed (e.g. to enforce ≤ 2^64 - 1 on a u64-like quantity), promote to a single-field structure with explicit conversion. Plan to migrate the call sites mechanically; the migration is breaking but tractable.
Bundled structures vs typeclasses
When a domain concept has several mathematical properties that must
hold together (e.g. "this is a function that decays from peak to zero
in a window"), prefer a bundled structure with the function and
proofs embedded, not a class-based typeclass.
-- ✓ Mathlib-aligned bundled-structure pattern (used by OrderHom, MulHom,
-- LinearMap, RingHom, ...):
structure WindowedDecay where
tBegin : Nat
tEnd : Nat
peak : Nat
apply : Nat → Nat
bounded : ∀ t, apply t ≤ peak
-- ... three more properties ...
-- Concrete-shape providers expose a constructor:
def LinearDecay.toWindowedDecay (tBegin tEnd peak : Nat) : WindowedDecay := { … }
Why bundled over typeclass:
- No instance-resolution surprises. A
WindowedDecayvalue is just a value; you pass it explicitly. No implicit-argument elaboration footguns (like thefun p {_ _} h_begin …workaround thatclass Decayneeded). - Mathlib does it this way. Their
OrderHom,MulHom,RingHom,LinearMap, etc. are all bundled structures, not classes. - Composition is plain function-call syntax.
d.complementary t,d.apply t— no typeclass magic. - Generic theorems live as methods on the structure, not as
separate generic functions over
[Decay T].
When to reach for a class: when you have a true type-level
interface (e.g. Monoid α, AddCommGroup α) where the typeclass
system's instance resolution is doing useful work — finding the
appropriate algebraic structure for Nat, Int, Rat, etc. For
domain-specific finance shapes with concrete parameter values, bundled
structures are usually the better tool.
Numeric layer: Solanalib.Numeric
A dedicated folder for numeric infrastructure that domain modules
build on. Currently houses Fraction (Q68.60 fixed-point, common in
Solana DeFi). Future additions: Fraction128 (the bounded
u128-backed refinement of Fraction), Q.96, etc.
The convention for spec-layer numeric types is:
Natunderneath, exposed through astructureso domain code can talk about "a Fraction" without exposing the encoding.omegaworks directly on the underlyingNat.- Operations carry their preconditions in the type
(
Fraction.sub (h : b.bits ≤ a.bits), likeAccount.debit). @[ext]plus@[simp]projection lemmas for every field / operation soext; simpproofs compose cleanly downstream.LE/LTinstances delegate to the underlyingNatorder; provideDecidableinstances explicitly — they don't come for free from theLEinstance, anddecidewill fail mysteriously without them.
instance : LE Fraction := ⟨fun a b => a.bits ≤ b.bits⟩
instance (a b : Fraction) : Decidable (a ≤ b) := Nat.decLe a.bits b.bits
Bounded UIntN-backed refinements live as separate types (e.g.
Fraction128) with .toNat-style bridges to the unbounded spec
version, mirroring the Lamports / LamportsUnchecked pattern.
Attribute usage
@[ext]on everystructure. Generates the extensionality lemmaFoo.ext : a.f₁ = b.f₁ → … → a = b. Costs nothing now, saves writing it later.@[simp]on lemmas that rewrite toward a normal form. The classic case: projection lemmas that expose a field after a constructor or update.@[simp] theorem credit_lamports (a : Account) (n : Lamports) : (credit a n).lamports = a.lamports + n := rflDon't tag the headline theorem (
transfer_preserves_total) with@[simp]— it's not a rewrite rule, it's a result.@[reducible]— almost never needed at small library scale.notationis the right answer for type synonyms.
Proof style
byat the end of the preceding line, never alone on its own line.- Tactic block indented 2 spaces.
- Subgoals marked with
·(centered dot), notcase. omegaforNat/Intlinear arithmetic.linarithfor ordered field arithmetic.decidefor closed decidable goals.simpvssimp only: prefersimp [foo, bar]for terminal calls — Mathlib explicitly discourages squeezing terminalsimptosimp onlybecause it makes proofs brittle to lemma renames. For non-terminalsimp, squeezing tosimp only [...]is fine for performance.simpnot closing →omega"no usable constraints": check whether one of your types is anabbrevoverNat. See## Type aliasesabove.λis forbidden — usefun x ↦ y(mapsto, not=>). Mathlib's linter rejectsλ.$is forbidden — use<|for left-pipe or|>for right-pipe.
Canonical proof shape:
theorem transfer_preserves_total
(src dst : Account) (amount : Lamports) (h : amount ≤ src.lamports) :
(transfer src dst amount h).source.lamports
+ (transfer src dst amount h).destination.lamports
= src.lamports + dst.lamports := by
simp [transfer]
omega
simp [transfer] unfolds the definition; @[simp] projection lemmas (e.g. credit_lamports, debit_lamports) then expose the field accesses; omega closes the resulting Nat arithmetic given h.
Composition
Theorems compose via have-binding inside larger proofs:
example (src dst extra : Account) (amount : Lamports) (h : amount ≤ src.lamports) :
let r := Account.transfer src dst amount h
r.source.lamports + r.destination.lamports + extra.lamports
= src.lamports + dst.lamports + extra.lamports := by
have := Account.transfer_preserves_total src dst amount h
omega
This is the pattern whenever a downstream proof needs an earlier result. omega will chain the have hypothesis with goal arithmetic.
Project structure
<Library>.lean -- root: imports the library's modules
<Library>/<Concept>/*.lean -- domain code, organised by concept
<Library>Test.lean -- root for the regression-test library
<Library>Test/<Concept>/*Test.lean
scripts/lint-style.sh -- textual style enforcement
- Don't put tests inside the main library directory — they'd ship in the published library.
- The library root re-exports everything; downstream users
import <Library>for everything or import individual files. - When a
<Concept>/Basic.leangrows beyond ~5 lemmas, split into<Concept>/Defs.lean(the bare definitions) and<Concept>/Basic.lean(core API + simp lemmas). Mathlib'sDefs.lean/Basic.lean/Lemmas.leansplit keeps recompile times sane.
CI workflows
ci.yml— runslake build+lake teston every push and PR, plus the textual lint script. Use explicitelan install+lake update+lake exe cache getrather thanleanprover/lean-action@v1— lean-action requires a pre-existinglake-manifest.jsonand you want CI to work even after a manifest-bumping PR.docs.yml— runslake -Kenv=dev build <Library>:docsand deploys.lake/build/doc/to GitHub Pages. Pages is configured with "Source: GitHub Actions" once viagh api -X POST /repos/<owner>/<repo>/pages -f build_type=workflow.update.yml— monthly cron +workflow_dispatchthat runslake -Kenv=dev updateand opens a PR with the manifest diff. Scaled down from Mathlib's hourly version.dependabot.yml— monthly grouped updates for GitHub Actions versions.
Common errors and fixes
invalid 'import' command, it must be used in the beginning of the file
Cause: A /-! ... -/ module docstring or any declaration sits above the import lines.
Fix: Order must be /- copyright -/ block-comment, then import lines, then /-! ... -/ module docstring. See ## File template.
omega could not prove the goal: No usable constraints found.
Cause #1 (most common): A type in the hypothesis is an abbrev over Nat (e.g. abbrev Lamports : Type := Nat). omega doesn't unfold it.
Fix: Change the abbrev to a notation declared after the namespace block. See ## Type aliases.
Cause #2: Bare simp consumed the hypothesis by applying a rewrite like Nat.sub_add_cancel.
Fix: Use simp only [...] to restrict the simp set, then omega. (For terminal simp Mathlib still prefers bare simp; only restrict when omega follows.)
lean-action@v1: No lake-manifest.json found. Run lake update to generate manifest
Cause: CI is calling leanprover/lean-action@v1 but no lake-manifest.json is committed.
Fix: Commit lake-manifest.json. Generate it with lake -Kenv=dev update locally, then git add lake-manifest.json.
unexpected token 'Foo'; expected identifier inside a namespace Foo declaration
Cause: notation "Foo" => Bar is declared before namespace Foo. The parser substitutes Foo and fails.
Fix: Move the notation declaration to after all namespace Foo blocks close in the file. Importing files are unaffected because dotted namespace names (A.B.C) are parsed as identifiers, not subject to token-level substitution.
Docs build is 404 on the Pages URL
Cause: The Docs workflow hasn't run successfully yet, or Pages isn't enabled with "GitHub Actions" as source.
Fix: Check .github/workflows/docs.yml runs are green; if not, fix the underlying error. If runs are green but Pages is still 404, enable Pages: gh api -X POST /repos/<owner>/<repo>/pages -f build_type=workflow.
Forbidden patterns (Mathlib lint)
λ(usefun … ↦ …)$(use<|/|>)- Empty lines inside a single declaration
- Indenting
namespace/sectionbodies (flush left) - Lines exceeding 100 characters
- Unicode outside the Mathlib allow-list (BiDi controls, invisibles)
- Bucket imports (
import Lean,import Mathlib) without justification — always import the specific module - Undisclosed LLM-generated content in PRs
References for deeper rules
- Mathlib4 style guide
- Mathlib4 naming conventions
- Mathlib4 doc string conventions
- The Mathlib4 source itself: representative files like
Mathlib/Algebra/Group/Basic.leanandMathlib/Init.leanshow the conventions in action.