Malli Reference
metosin/malli 0.20.0 · Clojure 1.11+ / ClojureScript 1.11.51+ High-performance data-driven schema library. Schemas are data — vectors, maps, keywords.
Setup
;; deps.edn
{:deps {metosin/malli {:mvn/version "0.20.0"}}}
;; Core requires
(require '[malli.core :as m])
(require '[malli.error :as me])
(require '[malli.transform :as mt])
(require '[malli.util :as mu])
(require '[malli.generator :as mg])
1. Schema Syntax
Schemas use hiccup-style vectors: type, [type & children], or [type properties & children].
;; Primitives
:string :int :double :boolean :keyword :symbol :uuid :any :nil
;; With constraints
[:string {:min 1, :max 100}]
[:int {:min 0, :max 999}]
;; Predicates (no properties support)
string? int? pos-int? nat-int? number? boolean? keyword? uuid? nil?
;; Enums and constants
[:enum "pending" "active" "cancelled"]
[:= 42]
;; Maybe (value or nil)
[:maybe :string]
;; Regex
[:re #"^\d{4}-\d{2}-\d{2}$"]
2. Schema Taxonomy (Mental Model)
Schemas fall into five categories:
- Base values — primitives like
:int,:string,:boolean, predicate schemas likeint? - Boxes — wrappers/references:
:maybe,:ref,:schema,:sequential,:vector,:set - Comparators —
:=,:not=,:>,:>=,:<,:<= - Conjunctions —
:and,:map,:cat/:catn,:tuple(all children must match) - Disjunctions —
:or/:orn,:multi,:alt/:altn,:enum(one child must match; these backtrack)
Sequence pattern schemas (:cat, :alt, :*, :+, :?, :repeat) are orthogonal — they describe sequential structure.
3. Map Schemas
;; Open by default (extra keys allowed)
(def Product
[:map
[:id :uuid]
[:name [:string {:min 1}]]
[:price :double]
[:category [:enum "food" "drink" "service"]]
[:description {:optional true} :string]])
;; Closed map (no extra keys)
[:map {:closed true}
[:x :int]
[:y :int]]
;; Qualified keys with local registry
[:map {:registry {::id :uuid, ::status [:enum "active" "archived"]}}
::id
[:name :string]
[::status {:optional true}]]
;; Homogeneous maps
[:map-of :string :int]
[:map-of :keyword Product]
;; Default schema for extra keys
[:map
[:x :int]
[:y :int]
[:malli.core/default [:map-of :keyword :any]]]
4. Collection Schemas
;; Sequential (any seq) [:sequential :int] ;; Vector (strict) [:vector :int] ;; Set [:set :keyword] ;; Tuple (fixed-length heterogeneous vector) [:tuple :double :double] [:tuple :string :int :boolean] ;; Seqable (any seqable) [:seqable :int] ;; Every (sampled validation for large uncounted collections) [:every :int]
:seqable vs :every: :every is bounded/sampled — it won't traverse the entire collection. This means it can miss invalid values at the tail:
(m/validate [:every :int] (conj (vec (range 1000)) nil)) ;; => false (vector, all checked) (m/validate [:every :int] (concat (range 1000) [nil])) ;; => true (lazy, sampled!)
5. Sequence Schemas (Regex-like)
;; Concatenation
[:cat :string :int]
[:catn [:name :string] [:age :int]]
;; Alternatives
[:alt :keyword :string]
[:altn [:kw :keyword] [:s :string]]
;; Repetition
[:? :int] ;; zero or one
[:* :int] ;; zero or more
[:+ :int] ;; one or more
[:repeat {:min 2, :max 4} :int]
;; Named subsequences for better error paths
[:* [:catn [:prop :string] [:val [:altn [:s :string] [:b :boolean]]]]]
6. Combinators
;; And (all must match)
[:and :int pos-int? [:< 100]]
;; Or (any must match)
[:or :string :int]
;; Not
[:not :int]
;; Fn predicate
[:fn {:error/message "must be positive"}
(fn [x] (pos? x))]
;; Fn on maps (cross-field validation)
[:and
[:map
[:password :string]
[:password2 :string]]
[:fn {:error/message "passwords don't match"
:error/path [:password2]}
(fn [{:keys [password password2]}]
(= password password2))]]
7. Multi Schemas (Polymorphic dispatch)
;; Dispatch on :type key
(def OrderItem
[:multi {:dispatch :type}
[:food [:map [:type [:= :food]] [:name :string] [:allergens [:set :keyword]]]]
[:drink [:map [:type [:= :drink]] [:name :string] [:volume :double]]]
[:service [:map [:type [:= :service]] [:name :string] [:duration :int]]]])
;; Dispatch with function
[:multi {:dispatch (fn [x] (if (:premium x) :premium :standard))}
[:premium [:map [:premium :boolean] [:vip-code :string]]]
[:standard [:map [:premium :boolean]]]]
Composing Multi Variants with :merge
Pull common fields into a base schema and merge per-variant to avoid duplication:
(def BaseEvent
[:map
[:T {:min 0} :int]
[:E {:min 0} :int]])
(def EventSchema
[:multi {:dispatch :e}
["GRID_UPDATE"
[:merge
BaseEvent
[:map
[:e [:= "GRID_UPDATE"]]
[:payload [:map [:symbol :string] [:status [:enum "NEW" "WORKING" "CANCELLED"]]]]]]]
["ORDER_REJECT"
[:merge
BaseEvent
[:map
[:e [:= "ORDER_REJECT"]]
[:reason :string]]]]])
;; Requires mu/schemas in registry
(def registry (merge (mu/schemas) (m/default-schemas)))
(m/validate EventSchema event {:registry registry})
8. Recursive Schemas
;; Using :ref and local registry
(def Category
[:schema {:registry {"category" [:map
[:id :uuid]
[:name :string]
[:subcategories [:vector [:ref "category"]]]]}}
"category"])
;; Using Var references
(def UserId :string)
(def Address [:map [:street :string] [:country [:enum "NL" "BE"]]])
(def User [:map [:id #'UserId] [:address #'Address]])
9. Validation
;; Basic
(m/validate :int 1) ;; => true
(m/validate :int "1") ;; => false
;; Precompiled validator (FAST — use in hot paths)
(def valid-product? (m/validator Product))
(valid-product? {:id (random-uuid) :name "Widget" :price 2.50 :category "food"})
;; => true
;; Explain (returns nil on success, error map on failure)
(m/explain Product {:id "bad" :name "" :price "nope"})
;; => {:schema ..., :value ..., :errors [...]}
;; Humanized errors
(-> Product
(m/explain {:id "bad" :name "" :price "nope"})
(me/humanize))
;; => {:id ["should be a uuid"], :name ["should be at least 1 characters"],
;; :price ["should be a double"], :category ["missing required key"]}
10. Parsing (Tagged Unions)
Schemas with alternation support parsing into tagged results:
;; orn → tagged map
(m/parse [:orn [:kw :keyword] [:s :string]] :foo)
;; => {:key :kw, :value :foo}
;; multi → tagged by dispatch key
(m/parse EventSchema event {:registry registry})
;; => {:key "GRID_UPDATE", :value {...}}
;; catn → named sequence parts
(m/parse
[:catn [:nums [:* :int]] [:flag :boolean] [:opts [:* :string]]]
[1 2 3 true "a" "b"])
;; => {:values {:nums [1 2 3], :flag true, :opts ["a" "b"]}}
;; unparse: reverse of parse
(m/unparse [:orn [:kw :keyword] [:s :string]] {:key :kw, :value :foo})
;; => :foo
11. Value Transformation (Coercion)
Built-in Transformers
| Transformer | Purpose |
|---|---|
mt/string-transformer | String ↔ EDN (form params, query strings) |
mt/json-transformer | JSON ↔ EDN (keywords from strings, sets from vectors) |
mt/strip-extra-keys-transformer | Drop extra keys from maps |
mt/default-value-transformer | Apply :default from schema properties |
mt/key-transformer | Transform map keys |
mt/collection-transformer | Collection type conversion |
Decode / Encode
;; String → int
(m/decode :int "42" mt/string-transformer) ;; => 42
;; JSON → EDN (keywords, sets)
(m/decode [:set :keyword] ["coffee" "tea"] mt/json-transformer)
;; => #{:coffee :tea}
;; Precompiled for performance
(def decode-product (m/decoder Product mt/json-transformer))
(def encode-product (m/encoder Product mt/json-transformer))
;; Compose transformers
(def strict-json
(mt/transformer
mt/strip-extra-keys-transformer
mt/json-transformer))
Coercion (Decode + Validate in one step)
;; Throws on invalid input (m/coerce :int "42" mt/string-transformer) ;; => 42 (m/coerce :int "bad" mt/string-transformer) ;; throws :malli.core/invalid-input ;; Precompiled coercer (def coerce-product (m/coercer Product mt/json-transformer)) ;; Exception-free coercion (CPS) (m/coerce :int "bad" nil (fn [v] (println "ok:" v)) (fn [e] (println "err:" (:value e)))) ;; prints: err: bad
Default Values
;; Simple defaults
(m/decode
[:map
[:status [:string {:default "draft"}]]
[:quantity [:int {:default 1}]]]
{}
mt/default-value-transformer)
;; => {:status "draft", :quantity 1}
;; Include optional keys
(m/decode
[:map
[:name [:string {:default "Unknown"}]]
[:note {:optional true} [:string {:default ""}]]]
{}
(mt/default-value-transformer {::mt/add-optional-keys true}))
;; => {:name "Unknown", :note ""}
;; Combine defaults + string decoding in one pass
(m/encode
[:map {:default {}}
[:count [:int {:default 0}]]
[:tags [:vector {:default []} :string]]]
nil
(mt/transformer
mt/default-value-transformer
mt/string-transformer))
;; => {:count "0", :tags []}
Custom Schema-Driven Transforms
;; Override per-schema decode/encode
[:string {:decode/string clojure.string/upper-case}]
;; With enter/leave stages
[:string {:decode/string {:enter clojure.string/trim
:leave clojure.string/lower-case}}]
;; Access schema properties in :compile
[:int {:multiplier 100
:decode/pricing {:compile (fn [schema _]
(let [m (:multiplier (m/properties schema))]
(fn [x] (* x m))))}}]
;; Named custom transformer
(mt/transformer {:name :pricing})
12. Error Messages
Custom Messages
;; Static message
[:int {:error/message "must be a whole number"}]
;; Dynamic message
[:fn {:error/fn (fn [{:keys [value]} _]
(str value " is not valid"))}
pos-int?]
;; Localized
[:enum {:error/message {:en "invalid size" :nl "ongeldige maat"}}
"S" "M" "L" "XL"]
;; Targeted error path
[:fn {:error/message "must be after start date"
:error/path [:end-date]}
(fn [{:keys [start-date end-date]}]
(.after end-date start-date))]
Spell Checking (Closed Maps)
(-> [:map [:street :string] [:city :string]]
mu/closed-schema
(m/explain {:streeet "Kerkstraat" :city "Amsterdam"})
me/with-spell-checking
me/humanize)
;; => {:streeet ["should be spelled :street"]}
Error Values Only
;; Just the erroneous parts
(-> schema (m/explain value) me/error-value)
;; With masking
(-> schema (m/explain value) (me/error-value {::me/mask-valid-values '...}))
13. Schema Manipulation (malli.util)
(require '[malli.util :as mu])
;; Navigate
(mu/get-in Address [:address :lonlat])
;; Modify
(mu/assoc-in Address [:address :country] [:enum "NL" "BE"])
(mu/dissoc Address :tags)
(mu/update-properties Address assoc :title "Address")
;; Optional/Required
(mu/optional-keys [:map [:x :int] [:y :int]])
(mu/optional-keys [:map [:x :int] [:y :int]] [:x])
(mu/required-keys [:map [:x {:optional true} :int]] [:x])
;; Open/Close
(mu/closed-schema some-map-schema) ;; recursive
(mu/open-schema some-map-schema) ;; recursive
;; Merge (last wins)
(mu/merge
[:map [:name :string] [:status [:enum "a" "b"]]]
[:map [:status :string] [:extra :int]])
;; => [:map [:name :string] [:status :string] [:extra :int]]
;; Union (both valid)
(mu/union
[:map [:x [:enum 1 2]]]
[:map [:x [:enum 2 3]]])
;; => [:map [:x [:or [:enum 1 2] [:enum 2 3]]]]
;; Select keys
(mu/select-keys Address [:id :tags])
14. Generation
(require '[malli.generator :as mg])
;; Single value
(mg/generate :string)
(mg/generate [:int {:min 0 :max 100}])
(mg/generate Product)
;; Multiple samples
(mg/sample [:enum "NL" "BE"] {:size 5})
;; Control generation
[:set {:gen/max 3} :keyword]
[:vector {:gen/min 1, :gen/max 5} :int]
;; Custom generator
[:int {:gen/gen (gen/return 42)}]
[:string {:gen/fmap clojure.string/upper-case}]
15. Schema Inference (Provider Workflow)
(require '[malli.provider :as mp])
;; Infer from samples
(mp/provide [{:id 1 :name "Widget" :price 2.5}
{:id 2 :name "Gadget" :price 3.0 :note "limited"}])
;; => [:map
;; [:id :int]
;; [:name :string]
;; [:price :double]
;; [:note {:optional true} :string]]
Infer → Review → Idealize
The provider gives a rough starting schema. Manually tighten it:
;; 1. Infer from raw data
(def inferred (mp/provide sample-data))
;; => [:map [:type :string] [:price :string] [:id :string] ...]
;; 2. Idealize: tighten types, add constraints
(def idealized
[:map
[:type [:= "snapshot"]]
[:price [decimal? {:min 0}]] ;; string → decimal with constraint
[:id :uuid] ;; string → uuid
[:status [:enum "NEW" "WORKING" "CANCELLED"]]]) ;; string → enum
;; 3. Coerce raw data through idealized schema
(m/coerce idealized raw-value mt/string-transformer)
This workflow is particularly useful for extraction pipelines where input data arrives as loose strings.
16. JSON Schema Output
(require '[malli.json-schema :as json-schema])
(json-schema/transform Product)
;; => {:type "object"
;; :properties {:id {:type "string" :format "uuid"} ...}
;; :required [:id :name :price :category]}
17. Function Schemas
;; Arrow syntax (simple)
[:-> :int :int]
[:-> :int :int :int]
;; Full syntax
[:=> [:cat :int :int] :int]
[:=> [:cat :string [:* :int]] :string]
;; Multi-arity
[:function
[:=> [:cat :int] :int]
[:=> [:cat :int :int] :int]]
;; Annotate functions
(defn calculate-total [items]
(reduce + (map :price items)))
(m/=> calculate-total [:=> [:cat [:vector Product]] :double])
;; Inline annotation
(defn plus1
"Adds one"
{:malli/schema [:=> [:cat :int] :int]}
[x] (inc x))
;; Instrumentation (dev only)
(require '[malli.dev :as dev])
(require '[malli.dev.pretty :as pretty])
(dev/start! {:report (pretty/reporter)})
(dev/stop!)
;; Emit clj-kondo configs from function schemas
(require '[malli.clj-kondo :as mc])
(mc/emit!)
18. Custom Schemas with -simple-schema
For domain-specific types, use m/-simple-schema to bundle predicate + transformer:
(defn -url-schema []
(m/-simple-schema
{:type :url
:pred (fn [x] (instance? java.net.URL x))
:type-properties
{:decode/string
(fn [s]
(when (string? s)
(try (java.net.URL. s)
(catch Exception _ s))))}}))
;; Use directly
(m/validate (-url-schema) (java.net.URL. "https://example.com"))
;; => true
;; With coercion from string
(m/coerce (-url-schema) "https://example.com" mt/string-transformer)
;; => #object[URL "https://example.com"]
;; Register in a custom registry
(def my-registry
(merge (m/default-schemas)
{:url (-url-schema)}))
(m/validate :url (java.net.URL. "https://example.com") {:registry my-registry})
19. Registries
;; Local registry (schema-level)
(m/validate
[:map {:registry {::item-id :uuid
::status [:enum "active" "archived"]}}
[::item-id]
[::status]]
{::item-id (random-uuid) ::status "active"})
;; Custom registry
(def my-registry
(merge
(m/default-schemas)
(mu/schemas)
{::pos-price [:and :double [:fn pos?]]
::item-name [:string {:min 1, :max 200}]}))
(m/validate ::pos-price 9.99 {:registry my-registry})
20. Practical Patterns
API Request Validation
(def CreateOrderRequest
[:map {:closed true}
[:tenant-id :uuid]
[:customer-name [:string {:min 1}]]
[:items [:vector {:min 1}
[:map
[:product-id :uuid]
[:quantity pos-int?]
[:notes {:optional true} :string]]]]
[:delivery-date :string]
[:special-instructions {:optional true} :string]])
(def coerce-order
(m/coercer CreateOrderRequest
(mt/transformer
mt/strip-extra-keys-transformer
mt/json-transformer)))
Multi-tenant Config Validation
(def TenantConfig
[:map
[:tenant-id :uuid]
[:locale [:enum "nl-NL" "nl-BE" "fr-BE" "en-GB"]]
[:currency [:enum "EUR" "GBP"]]
[:features [:map
[:ai-catalog {:optional true} :boolean]
[:custom-branding {:optional true} :boolean]]]
[:pricing [:multi {:dispatch :model}
[:fixed [:map [:model [:= :fixed]] [:price-per-month :double]]]
[:usage [:map [:model [:= :usage]] [:price-per-order :double]]]]]])
Structured Extraction Schema (AI Pipeline)
(def ExtractedProduct
[:map
[:name [:string {:min 1}]]
[:description {:optional true} :string]
[:price [:maybe :double]]
[:unit {:optional true} [:enum "piece" "kg" "liter" "portion"]]
[:category {:optional true} :string]
[:allergens {:optional true} [:set [:enum
"gluten" "dairy" "nuts" "eggs"
"soy" "fish" "shellfish"]]]])
(def ExtractionResult
[:map
[:products [:vector ExtractedProduct]]
[:confidence :double]
[:source-format [:enum "pdf" "excel" "image" "text"]]
[:warnings {:optional true} [:vector :string]]])
(def validate-extraction (m/validator ExtractionResult))
(def coerce-extraction
(m/coercer ExtractionResult
(mt/transformer
mt/json-transformer
mt/strip-extra-keys-transformer)))
Database Value Coercion (MySQL)
(def mysql-transformer
(mt/transformer
{:name :mysql
:decoders {:boolean (fn [x]
(cond
(instance? Boolean x) x
(number? x) (not (zero? x))
:else x))
:double (fn [x]
(if (instance? java.math.BigDecimal x)
(.doubleValue ^java.math.BigDecimal x)
x))
:int (fn [x]
(if (instance? Long x)
(int x)
x))}}))
21. Pretty Development Errors
(require '[malli.dev :as dev])
(require '[malli.dev.pretty :as pretty])
(dev/start! {:report (pretty/reporter)})
(pretty/explain Product {:id "bad"})
22. Walking Schemas
(m/walk
[:map [:x :int] [:y :string]]
(m/schema-walker
(fn [schema]
(mu/update-properties schema assoc :walked true))))
(mu/subschemas Product)
(mu/find-first some-schema
(fn [schema _ _]
(-> schema m/properties :my-prop)))
23. Naming Conventions (Best Practices)
- PascalCase for
def'd schemas:User,CampaignID,OrderItem ::kebab-casefor registry keyword refs:::query-params,::item-id- Consistent acronym casing:
UserIDnotUserId,URLnotUrl - No
-schemasuffix:UsernotUserSchema(doubly true in*.schemanamespaces) - Domain names over technical names:
CampaignIDnotIntRange100— schemas model domain entities
;; Good
(def CampaignID [:int {:max 100 :min 0}])
(def Message [:map [:campaign-id CampaignID]])
;; If the range truly appears in multiple unrelated domains, name both
(def IntRange0To100 [:int {:max 100 :min 0}])
(def CampaignID IntRange0To100)
(def ProgressPercent IntRange0To100)
24. Make Illegal States Impossible
Avoid optional keys that create invalid combinations. Split into explicit variants:
;; BAD: 4 possible states, only 2 are valid
[:map
[:common :string]
[:from-a {:optional true} SchemaA]
[:from-b {:optional true} SchemaB]]
;; GOOD: exactly 2 valid states
(def Common [:map [:common :string]])
(def FromA (mu/merge Common [:map [:from-a SchemaA]]))
(def FromB (mu/merge Common [:map [:from-b SchemaB]]))
(def Message [:or FromA FromB])
This also produces better generators — no invalid combinations in test data.
Prefer Narrowest Schema
Always ask: does this make sense for the domain?
;; Can you receive negative messages? No. [:map [:messages-received int?]] ;; too broad [:map [:messages-received nat-int?]] ;; zero or more [:map [:messages-received pos-int?]] ;; one or more (if 0 is invalid)
Prefer Built-ins Over Ad-hoc
;; Good: built-in with constraints
[:int {:min 0 :max 100}]
[:string {:max 256}]
;; Bad: ad-hoc fn predicates
[:and int? [:fn #(and (<= 0 %) (<= % 100))]]
[:and string? [:fn #(>= 256 (count %))]]
Built-ins give you better error messages, generators, and performance for free.
25. Performance Tips
- ~20x faster than spec for validation, thanks to compiled validator functions.
- Precompile everything:
m/validator,m/decoder,m/encoder,m/explainer,m/coercer— never callm/validateorm/decodein hot paths without precompiling. - Use
:sequentialover[:* type]when you just need homogeneous collection validation. - Use
:everyover:seqablefor large collections where sampling is acceptable. - Compose transformers once with
mt/transformerrather than running multiple passes. - Closed maps validate faster than open maps when you know the shape.
:multiwith:dispatchis faster than chained:orfor polymorphic maps.
Quick Reference: Common Operations
| Operation | Function | Returns |
|---|---|---|
| Validate | (m/validate schema value) | boolean |
| Validate (fast) | ((m/validator schema) value) | boolean |
| Explain | (m/explain schema value) | nil or error map |
| Humanize | (me/humanize (m/explain ...)) | human-readable map |
| Parse | (m/parse schema value) | tagged value or :malli.core/invalid |
| Unparse | (m/unparse schema value) | original value |
| Decode | (m/decode schema value transformer) | decoded value |
| Encode | (m/encode schema value transformer) | encoded value |
| Coerce | (m/coerce schema value transformer) | value or throws |
| Generate | (mg/generate schema) | random value |
| Infer | (mp/provide [samples...]) | schema |
| Merge | (mu/merge schema1 schema2) | merged schema |
| JSON Schema | (json-schema/transform schema) | JSON Schema map |
| Form | (m/form schema) | vector syntax |
| AST | (m/ast schema) | map syntax |
| Properties | (m/properties schema) | properties map |
| Children | (m/children schema) | child schemas |
| Type | (m/type schema) | schema type keyword |