A guide to using clj-kondo for Clojure code linting, including configuration, built-in linters, and writing custom hooks.
A comprehensive guide to using clj-kondo for Clojure code linting, including configuration, built-in linters, and writing custom hooks.
clj-kondo is a fast, static analyzer and linter for Clojure code. It:
brew install clj-kondo/brew/clj-kondo
Download from GitHub Releases:
# Linux
curl -sLO https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo
chmod +x install-clj-kondo
./install-clj-kondo
# Place in PATH
sudo mv clj-kondo /usr/local/bin/
clojure -Ttools install-latest :lib io.github.clj-kondo/clj-kondo :as clj-kondo
clojure -Tclj-kondo run :lint '"src"'
clj-kondo --version
# clj-kondo v2024.11.14
Lint a single file:
clj-kondo --lint src/myapp/core.clj
Lint a directory:
clj-kondo --lint src
Lint multiple paths:
clj-kondo --lint src test
src/myapp/core.clj:12:3: warning: unused binding x
src/myapp/core.clj:25:1: error: duplicate key :name
linting took 23ms, errors: 1, warnings: 1
Format: file:line:column: level: message
Human-readable (default):
clj-kondo --lint src
JSON (for tooling):
clj-kondo --lint src --config '{:output {:format :json}}'
EDN:
clj-kondo --lint src --config '{:output {:format :edn}}'
For better performance on subsequent runs:
clj-kondo --lint "$(clojure -Spath)" --dependencies --parallel --copy-configs
This caches analysis of dependencies and copies their configurations.
clj-kondo looks for .clj-kondo/config.edn in:
~/.config/clj-kondo/config.edn).clj-kondo/config.edn:
{:linters {:unused-binding {:level :warning}
:unused-namespace {:level :warning}
:unresolved-symbol {:level :error}
:invalid-arity {:level :error}}
:output {:pattern "{{LEVEL}} {{filename}}:{{row}}:{{col}} {{message}}"}}
:off - Disable the linter:info - Informational message:warning - Warning (default for most):error - Error (fails build)Disable specific linters:
{:linters {:unused-binding {:level :off}}}
Configure linter options:
{:linters {:consistent-alias {:aliases {clojure.string str
clojure.set set}}}}
Suppress warnings in specific namespaces:
{:linters {:unused-binding {:level :off
:exclude-ns [myapp.test-helpers]}}}
In source files:
;; Disable for entire namespace
(ns myapp.core
{:clj-kondo/config '{:linters {:unused-binding {:level :off}}}})
;; Disable for specific form
#_{:clj-kondo/ignore [:unused-binding]}
(let [x 1] 2)
;; Disable all linters for form
#_{:clj-kondo/ignore true}
(some-legacy-code)
Configurations merge in this order:
.clj-kondo/config.edn):unused-namespace - Warns about unused required namespaces
(ns myapp.core
(:require [clojure.string :as str])) ;; Warning if 'str' never used
;; Fix: Remove unused require
:unsorted-required-namespaces - Enforces sorted requires
{:linters {:unsorted-required-namespaces {:level :warning}}}
:namespace-name-mismatch - Ensures namespace matches file path
;; In src/myapp/utils.clj
(ns myapp.helpers) ;; Error: should be myapp.utils
:unused-binding - Warns about unused let bindings
(let [x 1
y 2] ;; Warning: y is unused
x)
;; Fix: Remove or prefix with underscore
(let [x 1
_y 2]
x)
:unresolved-symbol - Catches typos and undefined symbols
(defn foo []
(bar)) ;; Error: unresolved symbol bar
;; Fix: Define bar or require it
:unused-private-var - Warns about unused private definitions
(defn- helper []) ;; Warning if never called
;; Fix: Remove or use it
:invalid-arity - Catches incorrect function call arities
(defn add [a b] (+ a b))
(add 1) ;; Error: wrong arity, expected 2 args
;; Fix: Provide correct number of arguments
:missing-body-in-when - Warns about empty when blocks
(when condition) ;; Warning: missing body
;; Fix: Add body or use when-not/if
:duplicate-map-key - Catches duplicate keys in maps
{:name "Alice"
:age 30
:name "Bob"} ;; Error: duplicate key :name
:duplicate-set-key - Catches duplicate values in sets
#{1 2 1} ;; Error: duplicate set element
:misplaced-docstring - Warns about incorrectly placed docstrings
(defn foo
[x]
"This is wrong" ;; Warning: docstring after params
x)
;; Fix: Place before params
(defn foo
"This is correct"
[x]
x)
:type-mismatch - Basic type checking
(inc "string") ;; Warning: expected number
:invalid-arities - Checks arities for core functions
(map) ;; Error: map requires at least 2 arguments
Hooks are custom linting rules written in Clojure that analyze your code using clj-kondo's analysis data. They enable:
Use hooks when:
Hooks receive:
Hooks return:
.clj-kondo/
config.edn
hooks/
my_hooks.clj
.clj-kondo/hooks/my_hooks.clj:
(ns hooks.my-hooks
(:require [clj-kondo.hooks-api :as api]))
(defn my-hook
"Description of what this hook does"
[{:keys [node]}]
(let [sexpr (api/sexpr node)]
(when (some-condition? sexpr)
{:findings [{:message "Custom warning message"
:type :my-custom-warning
:row (api/row node)
:col (api/col node)}]})))
.clj-kondo/config.edn:
{:hooks {:analyze-call {my.ns/my-macro hooks.my-hooks/my-hook}}}
:analyze-call HooksTriggered when analyzing function/macro calls:
;; Hook for analyzing (deprecated-fn ...) calls
{:hooks {:analyze-call {my.api/deprecated-fn hooks.deprecation/check}}}
Hook implementation:
(defn check [{:keys [node]}]
{:findings [{:message "my.api/deprecated-fn is deprecated, use new-fn instead"
:type :deprecated-api
:row (api/row node)
:col (api/col node)
:level :warning}]})
:macroexpand HooksTransform macro calls for better analysis:
;; For macros that expand to def forms
{:hooks {:macroexpand {my.dsl/defentity hooks.dsl/expand-defentity}}}
Hook implementation:
(defn expand-defentity [{:keys [node]}]
(let [[_ name-node & body] (rest (:children node))
new-node (api/list-node
[(api/token-node 'def)
name-node
(api/map-node body)])]
{:node new-node}))
;; Get node type
(api/tag node) ;; => :list, :vector, :map, :token, etc.
;; Get children nodes
(api/children node)
;; Convert node to s-expression
(api/sexpr node)
;; Get position
(api/row node)
(api/col node)
(api/end-row node)
(api/end-col node)
;; String representation
(api/string node)
;; Create nodes
(api/token-node 'symbol)
(api/keyword-node :keyword)
(api/string-node "string")
(api/number-node 42)
(api/list-node [node1 node2 node3])
(api/vector-node [node1 node2])
(api/map-node [key-node val-node key-node val-node])
(api/set-node [node1 node2])
(api/token-node? node)
(api/keyword-node? node)
(api/string-node? node)
(api/list-node? node)
(api/vector-node? node)
(api/map-node? node)
Warn about deprecated function usage:
(ns hooks.deprecation
(:require [clj-kondo.hooks-api :as api]))
(defn warn-deprecated-fn [{:keys [node]}]
{:findings [{:message "old-api is deprecated. Use new-api instead."
:type :deprecated-function
:row (api/row node)
:col (api/col node)
:level :warning}]})
Config:
{:hooks {:analyze-call {mylib/old-api hooks.deprecation/warn-deprecated-fn}}}
Ensure specific argument types:
(ns hooks.validation
(:require [clj-kondo.hooks-api :as api]))
(defn validate-query-args [{:keys [node]}]
(let [args (rest (:children node))
first-arg (first args)]
(when-not (and first-arg (api/keyword-node? first-arg))
{:findings [{:message "First argument to query must be a keyword"
:type :invalid-argument
:row (api/row node)
:col (api/col node)
:level :error}]})))
Config:
{:hooks {:analyze-call {mylib/query hooks.validation/validate-query-args}}}
Expand custom DSL for better analysis:
(ns hooks.dsl
(:require [clj-kondo.hooks-api :as api]))
(defn expand-defrequest
"Expand (defrequest name & body) to (def name (request & body))"
[{:keys [node]}]
(let [[_ name-node & body-nodes] (:children node)
request-call (api/list-node
(list* (api/token-node 'request)
body-nodes))
expanded (api/list-node
[(api/token-node 'def)
name-node
request-call])]
{:node expanded}))
Config:
{:hooks {:macroexpand {myapp.http/defrequest hooks.dsl/expand-defrequest}}}
Warn about unsafe concurrent usage:
(ns hooks.concurrency
(:require [clj-kondo.hooks-api :as api]))
(defn check-atom-swap [{:keys [node]}]
(let [args (rest (:children node))
fn-arg (second args)]
(when (and fn-arg
(api/list-node? fn-arg)
(= 'fn (api/sexpr (first (:children fn-arg)))))
{:findings [{:message "Consider using swap-vals! for atomicity"
:type :concurrency-hint
:row (api/row node)
:col (api/col node)
:level :info}]})))
Ensure maps have required keys:
(ns hooks.maps
(:require [clj-kondo.hooks-api :as api]))
(defn validate-config-keys [{:keys [node]}]
(let [args (rest (:children node))
config-map (first args)]
(when (api/map-node? config-map)
(let [keys (->> (:children config-map)
(take-nth 2)
(map api/sexpr)
(set))
required #{:host :port :timeout}
missing (clojure.set/difference required keys)]
(when (seq missing)
{:findings [{:message (str "Missing required keys: " missing)
:type :missing-config-keys
:row (api/row node)
:col (api/col node)
:level :error}]})))))
;; test-hook.clj
(ns test-hook
(:require [mylib :as lib]))
(lib/deprecated-fn) ;; Should trigger warning
clj-kondo --lint test-hook.clj
Use clj-kondo.core for testing:
(ns hooks.my-hooks-test
(:require [clojure.test :refer [deftest is testing]]
[clj-kondo.core :as clj-kondo]))
(deftest test-my-hook
(testing "detects deprecated function usage"
(let [result (with-in-str "(ns test) (mylib/old-api)"
(clj-kondo/run!
{:lint ["-"]
:config {:hooks {:analyze-call
{mylib/old-api
hooks.deprecation/warn-deprecated-fn}}}}))]
(is (= 1 (count (:findings result))))
(is (= :deprecated-function
(-> result :findings first :type))))))
Include hooks with your library:
my-library/
.clj-kondo/
config.edn # Hook registration
hooks/
my_library.clj # Hook implementations
src/
my_library/
core.clj
Users get hooks automatically via --copy-configs.
Create a dedicated hook library:
;; deps.edn
{:paths ["."]
:deps {clj-kondo/clj-kondo {:mvn/version "2024.11.14"}}}
Users install via:
clj-kondo --lint "$(clojure -Spath -Sdeps '{:deps {my/hooks {:git/url \"...\"}}}')" --dependencies --copy-configs
Install Calva:
.clj-kondo directory recognitionWith flycheck-clj-kondo:
(use-package flycheck-clj-kondo
:ensure t)
With ALE:
let g:ale_linters = {'clojure': ['clj-kondo']}
name: Lint
on: [push, pull_request]
jobs:
clj-kondo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install clj-kondo
run: |
curl -sLO https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo
chmod +x install-clj-kondo
./install-clj-kondo
- name: Run clj-kondo
run: clj-kondo --lint src test
lint:
image: cljkondo/clj-kondo:latest
script:
- clj-kondo --lint src test
.git/hooks/pre-commit:
#!/bin/bash
clj-kondo --lint src test
exit $?
Begin with zero configuration - clj-kondo's defaults catch most issues.
For existing projects:
# Generate baseline
clj-kondo --lint src --config '{:output {:exclude-warnings true}}'
# Fix incrementally
Standardize via .clj-kondo/config.edn:
{:linters {:consistent-alias {:level :warning
:aliases {clojure.string str
clojure.set set}}}
:output {:exclude-files ["generated/"]}}
Write hooks for:
# Run once after dep changes
clj-kondo --lint "$(clojure -Spath)" --dependencies --parallel --copy-configs
Prefer fixing over ignoring. When ignoring:
;; Document why
#_{:clj-kondo/ignore [:unresolved-symbol]
:reason "Macro generates this symbol"}
(some-macro)
Unresolved symbol in macro:
;; Add to config
{:lint-as {myapp/my-macro clojure.core/let}}
Incorrect arity for variadic macro:
Write a macroexpand hook (see Custom Hooks section).
Slow linting:
# Cache dependencies
clj-kondo --lint "$(clojure -Spath)" --dependencies --parallel
# Exclude large dirs
{:output {:exclude-files ["node_modules/" "target/"]}}
Hook not triggering:
Hook errors:
# Run with debug output
clj-kondo --lint src --debug
Check:
.clj-kondo/config.edn (note the dot)clj-kondo is an essential tool for Clojure development offering:
Start with the defaults, customize as needed, and leverage hooks for your specific requirements.