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"
  ]
}
How the hook finds Python toolsRelying on PATH works only if every developer activated the virtual environment before committing, and fails in GUI clients. Running tools through the project's environment manager, or by explicit path, uses the pinned version every time.Works in GUI clientsUses pinned versionbare `ruff` on PATHoften notwhichever is found.venv/bin/ruffyesyesuv run / poetry runyesyesGUI Git clients do not load your shell profile — explicit paths survive that

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(" ")}`;
    },
  ],
};
Go files in, Go packages to the linterlint-staged passes the staged Go files to gofmt, which rewrites them in place. For golangci-lint, a function maps the same files to their directories, removes duplicates, and runs the linter once over those packages so cross-file symbols resolve.Staged *.gofile listgofmt -wper filedirname + dedupepackage listgolangci-lint./pkg/a ./pkg/blinting a lone file reports symbols from sibling files as undefined
# 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.

lint-staged or the pre-commit framework?If the repository already has Node and Husky, adding Python and Go tasks to lint-staged keeps one system. If there is no Node toolchain, the pre-commit framework avoids adding one and manages tool installation itself. In large polyglot repositories, the framework's isolated environments often win.What does the repository already have?Node + Huskylint-stagedone hook systemno Node at allpre-commitno new toolchainmany languagespre-commitmanaged environmentsmigrating later is straightforward in either direction

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.