Skip to main content

How to Implement Husky in a Production Project: From Installation to Production-Ready Hooks

Constantin Potapov
18 min

Step-by-step guide to implementing Husky and automating code quality through git hooks. Complete configs for JavaScript/TypeScript and Python projects.

Problem: Code Breaks After Commit

How many times has this happened to you: you push code to the repository, CI fails with a linter or test error that you could have caught locally in 5 seconds. Your colleagues are waiting, the pipeline is busy, and you're making yet another commit with a fix.

Or a classic: someone on the team commits code with console.log(), unused imports, or formatting "as it happened." Code review turns into a discussion about indentation instead of architecture.

Cost of the problem:

  • 10-15 minutes for each broken CI pipeline
  • Polluted commit history
  • Reviewers wasting time discussing formatting
  • Risk of broken code reaching production

Solution: automate checks before commit using git hooks and Husky.

Git hooks are scripts that automatically run at certain stages of Git workflow (before commit, before push, after checkout, etc.). Husky is a tool that simplifies setting up and managing these hooks in modern JavaScript projects.

What is Husky and Why You Need It

Husky is a popular npm package for managing git hooks in JavaScript projects. It allows you to:

  • Run checks automatically before commit or push
  • Format code before commit (via Prettier or ESLint --fix)
  • Run tests before pushing to remote repository
  • Validate commit messages (via commitlint)
  • Guarantee code quality at the developer's local machine level

Key advantage: problems are found before they get into the repository, not after.

0 min
Time fixing broken CI
100%
Code passes checks before commit
-80%
Formatting debates in reviews
5-10 sec
Check time before commit

What Husky Works With

Husky integrates excellently with popular tools:

ESLintPrettierTypeScriptJestVitestcommitlintlint-staged

Typical stack:

  • Husky — manages git hooks
  • lint-staged — runs checks only on changed files (not the entire project)
  • ESLint — checks code quality
  • Prettier — formats code
  • commitlint — validates commit message format

Installation and Basic Setup

Step 1: Installing Husky

# Install Husky (use version 9+)
npm install --save-dev husky
 
# Initialize Husky (creates .husky/ directory)
npx husky init

After running npx husky init, you'll have:

  • .husky/ directory with example pre-commit hook
  • "prepare": "husky" script in package.json

What the prepare script does:

This script automatically runs during npm install and sets up Husky for each developer who clones the repository. This ensures git hooks work for everyone on the team without additional actions.

Step 2: Installing lint-staged

lint-staged is a tool that runs checks only on files you've added to the staging area. This is critically important for performance: instead of checking 1000 files, you check only 5 changed ones.

npm install --save-dev lint-staged

Create a configuration file lint-staged.config.js in the project root:

module.exports = {
  // For JavaScript/TypeScript files
  "*.{js,jsx,ts,tsx}": [
    "eslint --fix", // Auto-fix ESLint issues
    "prettier --write", // Format with Prettier
  ],
 
  // For styles
  "*.{css,scss,less}": ["prettier --write"],
 
  // For JSON, Markdown and other files
  "*.{json,md,mdx,yml,yaml}": ["prettier --write"],
};

Important: Use lint-staged.config.js instead of inline configuration in package.json if you have complex logic or need comments.

Step 3: Configuring pre-commit Hook

Now let's configure the hook that will run before each commit.

Open the .husky/pre-commit file (created during initialization) and replace its content:

# .husky/pre-commit
npx lint-staged

That's it! Now before each commit, lint-staged will run and check only changed files.

Step 4: Testing

Let's verify the hooks are working:

# Create a file with ESLint error
echo "const unused = 'variable'" > test.js
 
# Add to staging
git add test.js
 
# Try to commit
git commit -m "test commit"

If everything is configured correctly, you'll see:

✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...

If ESLint found errors, the commit will be blocked:

✖ eslint --fix:
  error  'unused' is assigned a value but never used  no-unused-vars

✖ lint-staged failed

Advanced Setup: Production-Ready Configuration

Now let's configure Husky for a real project with TypeScript, tests, and commit validation.

lint-staged Configuration with TypeScript

For TypeScript projects, it's important not only to check with linter but also run type-checking:

// lint-staged.config.js
module.exports = {
  // TypeScript/JavaScript files
  "*.{ts,tsx,js,jsx}": [
    "eslint --fix --max-warnings=0", // Block commit if warnings exist
    "prettier --write",
  ],
 
  // TypeScript type checking (once for all files)
  "*.{ts,tsx}": () => "tsc --noEmit",
 
  // Styles
  "*.{css,scss,module.css}": ["prettier --write"],
 
  // Markdown and documentation
  "*.{md,mdx}": ["prettier --write"],
 
  // Configs
  "*.{json,yml,yaml}": ["prettier --write"],
};

Note: For TypeScript type-checking, use the () => "command" syntax because tsc checks the entire project, not individual files. This is important for the type system to work correctly.

Adding pre-push Hook for Tests

Running all tests before each commit is too slow. It's better to run them before push:

# Create pre-push hook
npx husky add .husky/pre-push "npm test"

Or if you use a specific test runner:

# .husky/pre-push
npm run test:ci  # Run tests without watch mode

Commit Message Validation with commitlint

Let's install commitlint to check commit format (e.g., Conventional Commits):

# Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
 
# Create config
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
 
# Add hook
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'

Now commits must follow the format:

feat: add new feature
fix: fix bug in auth module
docs: update documentation
chore: update dependencies

Attempting to commit with incorrect format will be blocked:

git commit -m "added feature"
# ⧗   input: added feature
# ✖   subject may not be empty [subject-empty]
# ✖   type may not be empty [type-empty]

Real Case: Next.js Project Setup

Here's a complete configuration from a production Next.js 15 project with TypeScript:

// lint-staged.config.js
module.exports = {
  // TypeScript and JavaScript
  "*.{ts,tsx,js,jsx}": ["eslint --fix --max-warnings=0", "prettier --write"],
 
  // Type-checking for TypeScript (entire project)
  "*.{ts,tsx}": () => "tsc --noEmit",
 
  // Styles and CSS Modules
  "*.{css,scss}": ["prettier --write"],
 
  // MDX content (blog, documentation)
  "*.{md,mdx}": ["prettier --write"],
 
  // JSON configs
  "*.json": ["prettier --write"],
};
# .husky/pre-commit
npx lint-staged
# .husky/pre-push
npm run build      # Check that build doesn't break
npm run test:ci    # Run tests
// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',     // New feature
        'fix',      // Bug fix
        'docs',     // Documentation
        'style',    // Formatting (doesn't affect code)
        'refactor', // Refactoring
        'test',     // Tests
        'chore',    // Update dependencies, configs
        'perf',     // Performance improvement
        'ci',       // CI/CD
        'build',    // Build system
        'revert',   // Revert commit
      ],
    ],
    'subject-case': [0], // Disable case checking for flexibility
  ],
};

Result: All code that gets into the repository automatically passes quality, formatting, and type checks. Probability of broken CI is nearly zero.

Husky for Python Projects

Husky isn't just for JavaScript! Git hooks work with any programming language. Let's configure Husky for a Python project with modern code quality tools.

Important: While Husky is an npm package, it works great with Python projects. You only need Node.js to install Husky itself, but the hooks will run Python tools.

Tools for Python

RuffBlackmypypytestisort

Modern stack for Python projects:

  • Ruff — ultra-fast linter and formatter (replaces Flake8, isort, and partially Black)
  • Black — uncompromising code formatter
  • mypy — type checking
  • pytest — testing
  • isort — import sorting (if not using Ruff)

Basic Python Setup

Step 1: Installing Husky

# Initialize npm project (if not exists)
npm init -y
 
# Install Husky
npm install --save-dev husky lint-staged
npx husky init

Step 2: Installing Python Tools

# Via pip
pip install ruff black mypy pytest
 
# Or via poetry
poetry add --group dev ruff black mypy pytest
 
# Or via requirements-dev.txt
echo "ruff>=0.1.0" >> requirements-dev.txt
echo "black>=23.0.0" >> requirements-dev.txt
echo "mypy>=1.7.0" >> requirements-dev.txt
echo "pytest>=7.4.0" >> requirements-dev.txt
pip install -r requirements-dev.txt

Step 3: Configuring lint-staged for Python

Create lint-staged.config.js:

module.exports = {
  // Python files: Ruff check and Black formatting
  "*.py": [
    "ruff check --fix", // Check and auto-fix via Ruff
    "black", // Format with Black
    "mypy --ignore-missing-imports", // Type checking
  ],
 
  // Jupyter notebooks (if using)
  "*.ipynb": ["ruff check --fix"],
 
  // YAML configs
  "*.{yml,yaml}": [
    "yamllint", // YAML linter
  ],
 
  // Markdown documentation
  "*.md": [],
};

Step 4: Configuring pre-commit Hook

# .husky/pre-commit
npx lint-staged

Step 5: Configuring pre-push Hook for Tests

# .husky/pre-push
pytest tests/                    # Run all tests

Option 1: Ruff Only (Fastest)

Ruff is a modern "all-in-one" tool for Python. It replaces Flake8, isort, pyupgrade, and partially Black.

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I", // Check and sort imports
    "ruff check --fix", // Check and auto-fix all rules
    "ruff format", // Formatting (Black alternative)
  ],
};

Ruff Configuration (pyproject.toml):

[tool.ruff]
# Maximum line length
line-length = 88
 
# Python version
target-version = "py311"
 
# Files to ignore
exclude = [
    ".git",
    ".venv",
    "__pycache__",
    "build",
    "dist",
]
 
[tool.ruff.lint]
# Rules to check (equivalent to Flake8, pycodestyle, isort, etc.)
select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # pyflakes
    "I",   # isort
    "N",   # pep8-naming
    "UP",  # pyupgrade
    "B",   # flake8-bugbear
    "C4",  # flake8-comprehensions
    "DTZ", # flake8-datetimez
    "T10", # flake8-debugger
    "SIM", # flake8-simplify
]
 
# Ignored rules
ignore = [
    "E501",  # line-too-long (Black handles this)
]
 
# Auto-fix rules
fixable = ["ALL"]
unfixable = []
 
[tool.ruff.format]
# Use double quotes
quote-style = "double"
 
# Indentation
indent-style = "space"
 
# Black compatibility
skip-magic-trailing-comma = false
line-ending = "auto"

Ruff advantage: Speed! Ruff is 10-100x faster than Flake8/Pylint and works in milliseconds even on large projects.

Option 2: Ruff + Black + mypy (Classic Stack)

If you want to use Black for formatting and mypy for type-checking:

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I", // Sort imports via Ruff
    "black --check", // Check formatting
    "black", // Apply formatting
    "ruff check --fix", // Lint via Ruff
    "mypy", // Type checking
  ],
};

Black Configuration (pyproject.toml):

[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
exclude = '''
/(
    \.git
  | \.venv
  | build
  | dist
)/
'''

mypy Configuration (pyproject.toml):

[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
 
# Ignore missing types in libraries
ignore_missing_imports = true
 
# Strict mode (optional)
# strict = true

Option 3: Pylint Instead of Ruff (For Strict Checks)

Pylint is a stricter and more detailed linter, but slower than Ruff.

// lint-staged.config.js
module.exports = {
  "*.py": [
    "black", // Formatting
    "isort", // Import sorting
    "pylint --errors-only", // Errors only (faster)
    // "pylint",                     // Full check (slow)
    "mypy", // Type checking
  ],
};

Pylint Configuration (.pylintrc):

[MASTER]
# Ignored files
ignore=CVS,.git,__pycache__,.venv
 
# Number of processes (0 = auto-detect)
jobs=0
 
[MESSAGES CONTROL]
# Disabled rules
disable=
    C0111,  # missing-docstring
    C0103,  # invalid-name
    R0903,  # too-few-public-methods
    W0212,  # protected-access
 
[FORMAT]
# Maximum line length
max-line-length=88
 
# Indentation
indent-string='    '
 
[DESIGN]
# Maximum function arguments
max-args=7
 
# Maximum class attributes
max-attributes=10

Warning: Pylint is 50-100x slower than Ruff. On large projects, this can slow commits to 10-30 seconds. It's recommended to use --errors-only or move to pre-push.

Optimization: Fast Checks in pre-commit, Slow in pre-push

For large Python projects, it's recommended to split checks:

Fast checks (pre-commit):

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I", // Imports
    "ruff format", // Formatting
    "ruff check --fix", // Fast checks
  ],
};

Slow checks (pre-push):

# .husky/pre-push
#!/bin/sh
 
# Type checking for entire project
echo "Running mypy type checking..."
mypy src/
 
# Full tests
echo "Running pytest..."
pytest tests/ -v
 
# Test coverage check (optional)
# pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=80

Real Case: FastAPI Project with Poetry

Complete configuration for production FastAPI project:

# pyproject.toml
[tool.poetry]
name = "my-fastapi-app"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
 
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104.0"
uvicorn = "^0.24.0"
pydantic = "^2.5.0"
sqlalchemy = "^2.0.0"
 
[tool.poetry.group.dev.dependencies]
ruff = "^0.1.0"
black = "^23.11.0"
mypy = "^1.7.0"
pytest = "^7.4.0"
pytest-cov = "^4.1.0"
pytest-asyncio = "^0.21.0"
httpx = "^0.25.0"
 
# Ruff configuration
[tool.ruff]
line-length = 88
target-version = "py311"
exclude = [".venv", "migrations"]
 
[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "UP", "B", "C4", "SIM"]
ignore = ["E501"]
fixable = ["ALL"]
 
# Black configuration
[tool.black]
line-length = 88
target-version = ['py311']
exclude = '''/(\.venv|migrations)/'''
 
# mypy configuration
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
plugins = ["pydantic.mypy"]
 
[[tool.mypy.overrides]]
module = "sqlalchemy.*"
ignore_missing_imports = true
 
# pytest configuration
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_functions = "test_*"
asyncio_mode = "auto"
// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I",
    "ruff format",
    "ruff check --fix",
    "mypy",
  ],
  "*.{json,yml,yaml}": [],
};
# .husky/pre-commit
npx lint-staged
# .husky/pre-push
#!/bin/sh
 
echo "🔍 Running type checking..."
poetry run mypy src/
 
echo "🧪 Running tests..."
poetry run pytest tests/ -v --cov=src --cov-report=term-missing
 
echo "✅ All checks passed!"
// package.json
{
  "name": "my-fastapi-app",
  "private": true,
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0"
  }
}

Django Project: Configuration Specifics

For Django projects, add migration checks:

// lint-staged.config.js
module.exports = {
  "*.py": [
    "ruff check --fix --select I",
    "ruff format",
    "ruff check --fix",
    "mypy --ignore-missing-imports",
  ],
 
  // Check migrations when models change
  "**/models.py": () => "python manage.py makemigrations --check --dry-run",
};
# .husky/pre-push
#!/bin/sh
 
echo "🔍 Checking migrations..."
python manage.py makemigrations --check --dry-run
 
echo "🧪 Running tests..."
python manage.py test
 
echo "🔐 Running security checks..."
python manage.py check --deploy
 
echo "✅ All checks passed!"

Comparing Python Tools

Traditional stack
Modern stack
Tool
Pylint
Ruff
Check speed (1000 files)
~45 seconds
~0.5 seconds
99%
Check strictness
Very high
High
Auto-fix
No
Yes

Recommendations:

  • For new projects: Ruff + mypy (fast, modern, strict enough)
  • For legacy projects: Gradual migration from Pylint to Ruff
  • For strict corporate standards: Ruff + Pylint (errors only) + mypy

Checklist for Python Projects

  • Husky and lint-staged installed
  • Python tools installed (Ruff/Black/mypy)
  • pyproject.toml created with configuration
  • lint-staged.config.js configured for Python files
  • .husky/pre-commit configured for fast checks
  • .husky/pre-push configured for tests
  • .gitignore added for Python (.venv, __pycache__, etc.)
  • Tested: commit with error is blocked
  • Tested: commit with valid code passes

Problems and Solutions

Problem 1: Hooks Don't Work After Cloning

Symptom: Colleague cloned the repository, but git hooks don't run.

Cause: prepare script didn't run during npm install.

Solution:

Make sure package.json contains:

{
  "scripts": {
    "prepare": "husky"
  }
}

Ask colleagues to run after cloning:

npm install  # This will run prepare automatically

Problem 2: Hooks Are Too Slow

Symptom: Commit takes 30+ seconds.

Cause: Checks run on all project files, not just changed ones.

Solution:

  1. Use lint-staged — it checks only staged files
  2. Don't run tests in pre-commit, move them to pre-push
  3. Use caching in ESLint:
// lint-staged.config.js
module.exports = {
  "*.{ts,tsx,js,jsx}": [
    "eslint --cache --fix", // Add --cache
    "prettier --write",
  ],
};

Problem 3: Need to Bypass Hooks in Emergency

Symptom: Need to commit urgently, but hooks are blocking.

Solution (use with caution!):

# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "emergency fix"
 
# Skip pre-push hook
git push --no-verify

Don't abuse --no-verify! Use only in emergency cases (production hotfix). In other cases, fix the errors found by the linter.

Problem 4: ESLint Complains About Files That Shouldn't Be Checked

Cause: lint-staged passes files to ESLint that should be ignored.

Solution:

Create/update .eslintignore:

# .eslintignore
node_modules/
.next/
out/
dist/
build/
*.config.js

Or configure lint-staged explicitly:

module.exports = {
  "*.{ts,tsx,js,jsx}": (filenames) => {
    const filteredFiles = filenames
      .filter((file) => !file.includes("node_modules"))
      .filter((file) => !file.includes(".next"));
 
    return `eslint --fix ${filteredFiles.join(" ")}`;
  },
};

Implementing in Existing Project (Migration)

If you already have a large project with existing code that doesn't pass checks:

Strategy 1: Gradual Implementation

  1. Install Husky and lint-staged
  2. Configure Prettier only (auto-fix doesn't break code):
// lint-staged.config.js
module.exports = {
  "*.{ts,tsx,js,jsx}": ["prettier --write"],
};
  1. After a week, add ESLint with --fix:
module.exports = {
  "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
};
  1. After a month, add type-checking and tests

Strategy 2: Check Only New Files

Use --max-warnings for gradual improvement:

module.exports = {
  "*.{ts,tsx,js,jsx}": [
    "eslint --fix --max-warnings=10", // Allow 10 warnings, gradually decrease
    "prettier --write",
  ],
};

Each sprint, reduce the limit: 10 → 5 → 0.

Strategy 3: Ignoring Legacy Code

Add legacy files to .eslintignore:

# Legacy code being gradually refactored
src/legacy/
src/old-api/

New code is checked strictly, old code is gradually refactored.

Scripts for package.json

Useful commands for working with git hooks:

{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint . --ext .ts,.tsx,.js,.jsx",
    "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix",
    "format": "prettier --write \"**/*.{ts,tsx,js,jsx,css,md,json}\"",
    "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,css,md,json}\"",
    "type-check": "tsc --noEmit",
    "test:ci": "vitest run",
    "validate": "npm run lint && npm run type-check && npm run test:ci"
  }
}

The validate command is useful for checking the entire project (e.g., before creating a PR):

npm run validate

Success Metrics

After a month of using Husky, you should see:

Before Husky
After Husky
Broken CI pipelines
5-10 per week
0-1 per month
100%
Time fixing CI
15-20 min
0 min
100%
Formatting debates
Every review
None
Production errors
2-3 per month
0-1 per month
100%

Additional metrics:

  • Code review time reduced by 30-40% (no need to discuss formatting)
  • Code quality improves (automated checks find issues early)
  • Less team stress (CI rarely fails)

Implementation Checklist

Use this checklist for implementing Husky in your project:

  • Husky installed (npm install --save-dev husky)
  • Initialization completed (npx husky init)
  • lint-staged installed (npm install --save-dev lint-staged)
  • lint-staged.config.js created with checks
  • .husky/pre-commit configured to run lint-staged
  • (Optional) .husky/pre-push configured for tests/build
  • (Optional) commitlint installed for commit validation
  • prepare script added to package.json
  • Tested: commit with ESLint error is blocked
  • Tested: commit with valid code passes
  • Team informed about new rules
  • Documentation updated (README or CONTRIBUTING.md)

Conclusion

Husky and git hooks aren't just "convenient automation." This is a culture shift in team development.

What you get:

  • Code always passes minimum quality checks before getting into the repository
  • CI/CD pipelines stop failing due to trivial errors
  • Code review focuses on architecture and logic, not formatting
  • New developers immediately get automated checks after npm install

Implementation time:

  • Basic setup: 15-30 minutes
  • Production-ready configuration: 1-2 hours
  • ROI: Already in a week (time saved fixing CI)

Next step:

Open your terminal right now and run:

npm install --save-dev husky lint-staged
npx husky init

Create lint-staged.config.js from the examples above — and your project is already protected from trivial errors.

Quality automation isn't about control, it's about letting developers focus on what matters while routine checks run themselves.