lint-staged for Python and Go projects Jump to heading
lint-staged comes from the JavaScript world, but nothing about it is JavaScript-specific: it takes staged files matching a glob and runs a command on them. That makes it a reasonable choice for Python and Go code in repositories that already have a Node toolchain — a web front end alongside a Python API, or a Go service with a TypeScript admin panel. The details differ by language. Python formatters and linters take file lists happily. Go tools often want packages rather than files, and golangci-lint in particular gives misleading results when handed individual files. This page configures both languages correctly and explains when the pre-commit framework is the better host, within lint-staged formatting automation.
When to use this approach Jump to heading
- A repository mixes JavaScript or TypeScript with Python or Go, and already uses Husky and lint-staged.
- You want one hook system for the whole repository rather than two.
- The Python and Go tools are installed in a predictable place for every developer.
- For repositories without Node at all, the pre-commit framework for polyglot repos is usually the simpler choice.
Step 1 — Configure Python formatting and linting Jump to heading
Ruff handles both linting with fixes and formatting, fast enough to run on every commit. Pass it the staged files directly.
// .lintstagedrc.json
{
"*.py": ["ruff check --fix --exit-non-zero-on-fix", "ruff format"]
} --exit-non-zero-on-fix makes Ruff fail when it changed something, so the commit stops and you see what was fixed. Without it, fixes are silently added to the commit, which some teams prefer; choose deliberately.
If the project uses Black and isort rather than Ruff, the same shape works:
{ "*.py": ["isort", "black"] } # Verification: a badly formatted file is fixed and re-staged
printf 'import os,sys\nx=1\n' > scratch_check.py && git add scratch_check.py
npx lint-staged && git diff --cached scratch_check.py
git reset -q scratch_check.py && rm scratch_check.py Step 2 — Make sure the right Python environment is used Jump to heading
Hooks run with whatever PATH Git gives them, which may not include the project’s virtual environment. Run Python tools through the environment manager, or through an explicit path, so the hook uses the project’s pinned versions.
{
"*.py": [
"uv run ruff check --fix",
"uv run ruff format"
]
} The same PATH problem affects editors and GUI clients generally; it is covered in Husky hooks in GUI clients and IDEs.
Step 3 — Run gofmt on files, golangci-lint on packages Jump to heading
gofmt and goimports work file by file. golangci-lint, however, analyses packages: given individual files, it treats them as an isolated package and reports undefined symbols that are defined in sibling files. Use lint-staged’s function form to convert staged files into their package directories.
// .lintstagedrc.mjs
import path from "node:path";
export default {
"*.go": [
"gofmt -w",
(files) => {
const pkgs = [...new Set(files.map((f) => "./" + path.dirname(path.relative(process.cwd(), f))))];
return `golangci-lint run ${pkgs.join(" ")}`;
},
],
}; # Verification: linting the package succeeds where linting a single file reports false errors
golangci-lint run ./internal/billing/ # correct
golangci-lint run internal/billing/totals.go # may report 'undefined: roundHalfEven' golangci-lint also supports --new-from-rev=HEAD to report only issues introduced since a revision, which keeps the hook focused on your change in a codebase with existing warnings.
Step 4 — Keep tool versions consistent with CI Jump to heading
Formatters that differ by a version produce endless churn: one developer’s hook reformats what another’s just formatted. Pin versions in the project — pyproject.toml with a lockfile for Python, a tools.go file or go install with a version for Go — and use the same versions in CI.
# Go: install pinned tool versions into a project-local bin directory
GOBIN="$PWD/.bin" go install github.com/golangci/golangci-lint/cmd/[email protected]
printf '.bin/\n' >> .gitignore { "*.go": ["gofmt -w", "./.bin/golangci-lint run --new-from-rev=HEAD"] } Step 5 — Decide between lint-staged and pre-commit Jump to heading
Both tools solve the same problem. The choice usually follows the repository’s centre of gravity.
If you switch, the migration path is in migrating from Husky to the pre-commit framework.
Step 6 — Handle generated code and vendored directories Jump to heading
Python and Go projects often contain files nobody should reformat: generated protobuf stubs, vendored modules, migration files that must stay byte-identical. Exclude them explicitly, both in lint-staged globs and in the tools’ own configuration, so a staged change to a generated file is not rewritten.
// .lintstagedrc.mjs
export default {
"*.py": (files) => {
const own = files.filter((f) => !/\/(migrations|_pb2(_grpc)?\.py$)/.test(f));
return own.length ? [`ruff check --fix ${own.join(" ")}`, `ruff format ${own.join(" ")}`] : [];
},
"*.go": (files) => {
const own = files.filter((f) => !f.includes("/vendor/") && !f.endsWith(".pb.go"));
return own.length ? [`gofmt -w ${own.join(" ")}`] : [];
},
}; Mirror the exclusions in pyproject.toml (extend-exclude) and .golangci.yml (exclude-dirs), so CI and editors agree with the hook about which files are hand-written.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Can lint-staged run go vet? Jump to heading
Yes, with the same package mapping as golangci-lint. go vet analyses packages, so pass directories, not files.
Why does Ruff fix things but the commit still contains the old code? Jump to heading
If the file was partially staged, lint-staged applied the fixes to the staged version and restored your unstaged edits on top. Check git diff --cached; the committed version has the fixes. The mechanism is described in handling partially staged files.
Do we still need these checks in CI? Jump to heading
Yes. Hooks run on developer machines and can be skipped. CI runs the same tools at the same versions on every pull request and is the enforcement point.
Related Jump to heading
- Lint-Staged Formatting Automation — the parent topic.
- Configuring lint-staged in a Monorepo — per-package configs for mixed repos.
- Writing a Custom Local pre-commit Hook — the framework alternative for custom checks.
- Running Type Checks Alongside lint-staged — mypy and pyright in the same hook.