Lint and Format Code in CI
Learn to lint and format code in CI with this CI/CD foundations tutorial. Step-by-step instructions, troubleshooting, and next steps.
Focus: lint and format code in ci
Your pull request looks perfect — clean, reviewed, and green on tests. Then you merge it, and three weeks later a teammate opens utils.py, sees 14 different quoting styles, a function that's 90 lines when it should be 20, and a variable named x2 that could mean anything. Nobody noticed because nobody checked style, and now every code review is a fight about formatting instead of a conversation about logic. That's the silent productivity killer this lesson fixes: linting and formatting code in CI — the automated guardrail that keeps your codebase consistent, your reviews fast, and your merges safe.
The Problem This Lesson Solves
Inconsistent code is expensive. When every developer has their own style — some use tabs, some spaces, some prefer single quotes, others double — the codebase becomes a patchwork. Reading it slows you down. Merging it produces noisy diffs that hide real changes. And worse, style debates leak into code reviews, where they burn time that should be spent on architecture and bugs.
A linter is a static analysis tool that scans your code for problematic patterns — unused variables, missing error handling, overly complex functions, style violations. A formatter automatically rewrites your code to follow a consistent style guideline, like PEP 8 for Python or Prettier for JavaScript. When you run both in CI (Continuous Integration), every pull request gets checked automatically. The feedback is instant: a red X on the commit that says "your code doesn't meet our standards."
But here's the real pain: without CI enforcement, these checks exist only locally. Developers forget to run them, or they skip them to "save time," and the drift begins. With CI, you enforce rather than hope. The result: a codebase that reads like one person wrote it, even when a dozen people contribute.
Core Concept / Mental Model
Think of linting and formatting as automated code review assistants that work before human eyes see your code. The linter is your“grammar checker” — it catches mistakes and confusing patterns. The formatter is your“auto-correct” — it rewrites to a standard style.
In CI, these tools run in a check job that runs on every pull request. The flow looks like this:
- A developer pushes a branch.
- CI spins up a clean virtual machine or container.
- CI runs
lintandformatcommands. - If any check fails, the build is marked red, and the PR is blocked from merging.
- If all checks pass, the PR can proceed to human review and merge.
This is a gate. You're not just collecting feedback — you're blocking the merge until standards are met. The gate is what makes the system work.
Pro tip: Formatting and linting are complementary. Formatting covers what the linter often does not — spacing, line breaks, quote style. Linting covers what formatting cannot — unused variables, bad patterns, security smells. Use both.
How It Works Step by Step
Here's how you wire linting and formatting into a typical CI/CD pipeline, using GitHub Actions as the example (the track's primary platform).
Step 1: Choose your tools
For Python, the most common pair is Ruff (linter and formatter, fast and modern) or Black (formatter) + Flake8 or Pylint (linter). For JavaScript/TypeScript, you'd use ESLint (linter) and Prettier (formatter). This lesson uses Python with Ruff, but the pattern transfers.
Step 2: Configure locally
Before CI, you set up your project so tools run consistently. For Ruff, that means a pyproject.toml section:
[tool.ruff]
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B"]
And you add scripts to pyproject.toml so commands are short:
[tool.pytest.ini_options]
# ...
[project.scripts]
# ...
Or simpler, use Makefile or scripts in package.json for JS. The point: you define the standard once.
Step 3: Write the CI workflow
You create a workflow file, say .github/workflows/lint.yml, that runs on every PR.
Step 4: Run and react
When a PR is opened, CI runs the workflow. If it passes, green. If it fails, the PR author sees the error output and fixes it, usually by running the formatter locally and committing the result.
Step 5: Enforce with branch protection
On GitHub, you require the lint job to pass before merging. That's the enforcement.
Hands-On Walkthrough
Let's build a complete example. Start with a minimal Python project:
my_project/
├── src/
│ └── main.py
├── tests/
│ └── test_main.py
├── pyproject.toml
└── .github/
└── workflows/
└── ci.yml
1. Install the tool (locally, to verify):
pip install ruff
2. Write a deliberately messy file to demonstrate:
# src/main.py
def add(a,b):
return a+b
def unused_function():
print("I'm never called")
print(add(1,2))
3. Run the linter and formatter locally:
ruff check src/
ruff format src/
You'll see the linter complain about missing spaces, unused function, and maybe import order. The formatter will rewrite the file to:
# src/main.py
def add(a, b):
return a + b
def unused_function():
print("I'm never called")
print(add(1, 2))
4. Now create the CI workflow. Here's a .github/workflows/ci.yml that does lint and format check:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Ruff
run: pip install ruff
- name: Lint
run: ruff check .
- name: Format check
run: ruff format --check .
Expected output in the Actions UI when you push this:
Run ruff check .
Found 2 errors.
* E501 line too long (92 > 88)
* F811 redefinition of unused 'add' from line 1
The workflow fails, and the PR shows a red X. That's the gate working.
5. Fix the code and re-push. You'd run ruff format . and fix the import or unused function. Then the job goes green.
Now test the enforcement: open a PR with a style violation and see it blocked. Then fix, push, and watch it pass.
Compare Options / When to Choose What
Not all linters and formatters are equal. Here's a quick comparison for Python:
| Tool | Type | Speed | Style Enforcement | Extensibility | Use When |
|---|---|---|---|---|---|
| Ruff | Linter + formatter | Very fast | PEP 8, Pyflakes, many plugins | Great | Modern projects, want speed and one tool |
| Black | Formatter only | Medium | PEP 8, uncompromising | Limited | Want consistent formatting only |
| Flake8 | Linter only | Medium | PEP 8, Pyflakes | Good | Prefer traditional separate tools |
| Pylint | Linter only | Slow | Extensive checks | Excellent | You want deep analysis and are okay with false positives |
For JavaScript: ESLint + Prettier is the standard. For Go: gofmt is built-in and mandatory. For Rust: rustfmt and clippy.
When to choose what?
- Greenfield project, Python: Use Ruff. It's fast, modern, and does both jobs.
- Existing project with many devs: Use
black --check+flake8because they're widely understood and have few surprises. - JavaScript/TypeScript: Always use ESLint + Prettier, enforced in CI.
- Community projects: Match the community standard — don't invent your own rules.
Pro tip: Start with the defaults of your tools. Custom rules are tempting, but they add maintenance and can confuse new contributors. Only add custom rules when you have a specific, recurring problem.
Troubleshooting & Edge Cases
Here are the common failures you'll hit and how to fix them:
1. "Format check fails, but I ran the formatter locally!"
The classic cause: you ran the formatter only on a subset of files, but CI checks the whole repo. Fix: run ruff format . from the repo root and commit everything. Also check your local version matches the CI version — differences in tool versions can produce different formatting. Pin the version in CI and locally.
2. Linter freezes or times out on large monorepos
Linting a huge codebase can be slow. Fix: configure paths in pyproject.toml to exclude directories like venv/, and use incremental checks with ruff check --diff or split into parallel jobs. In CI, use caching of dependencies to avoid re-installing the linter every time.
3. My code fails lint but I think it's wrong
Linters are opinionated and sometimes produce false positives. Check your configuration — you may have enabled rules you didn't intend. Use ruff check --explain RULE_CODE to understand a rule, and either fix the code or allowlist the specific line with # noqa if it's genuinely a false positive.
4. PR passes lint, but I want to enforce it on contributors
You need branch protection rules on GitHub. Without them, a red check is just a warning — contributors can still merge. Go to Settings > Branches > Add rule, name it main, and require status checks to pass, including the lint job.
5. Formatting changes create huge diffs in every PR
If you introduce a formatter to a legacy codebase, the first run will reformat everything and create a massive PR. Strategy: run the formatter once in a dedicated commit (before CI enforcement), then enforce after it's merged. Also consider using --check only for a while, not auto-fixing, to keep diffs small.
What You Learned & What's Next
You have now completed the lint and format code in CI lesson. Let's recap what you mastered:
- You understand the core idea: linting catches problematic patterns, formatting enforces consistent style, and both run automatically in CI to gate merges.
- You can apply the concept hands-on: you created a GitHub Actions workflow that runs Ruff, saw it fail on a messy file, fixed the code, and watched it pass.
- You know how to compare tools and choose what fits your project.
- You understand troubleshooting — version mismatches, path issues, and enforcement lapses.
This skill is a cornerstone of a healthy codebase. The next lesson in our CI/CD foundations track moves from style to substance — you'll learn how to automate testing and coverage in CI, ensuring both style and behavior are verified before merge. You'll reuse your workflow structure and add test jobs, so stay with it — your CI pipeline is about to get much more powerful.
Until then, go enforce linting and formatting on your own project. Your future self (and your teammates) will thank you.
Practice recap
To solidify, create a fresh GitHub repository with a one-file Python project, add a messy file, and set up a GitHub Actions workflow that runs ruff check and ruff format --check. Open a PR and watch it fail, then fix the code and make it pass. Finally, enable branch protection to require the lint job before merging.
Common mistakes
- Only linting and not formatting — or vice versa. Linters catch patterns, formatters catch style; you need both for full coverage.
- Forgetting to pin the tool version. A local Ruff 0.3 vs CI's 0.5 can produce different results, causing mysterious CI failures.
- Enabling a lint rule without documenting why, leading to maintenance burden and contributor frustration.
- Not configuring branch protection — a passing check is just a badge; you must require it for merge to actually enforce style.
- Running the formatter only on changed files while CI checks the whole repo, causing unexpected failures.
Variations
- Use Black + Flake8 as a separate formatter and linter, instead of a combined tool like Ruff.
- For JavaScript/TypeScript projects, use ESLint and Prettier in CI with the same workflow pattern.
- In monorepos, split lint jobs per path or per package to speed up CI and test only affected code.
Real-world use cases
- Automatically blocking merge of a PR that has unused imports or inconsistent indentation in a Python web app on GitHub.
- Enforcing JavaScript style with ESLint + Prettier on a large React codebase to keep reviews focused on logic.
- Standardizing formatting across a polyglot monorepo (Python + Go) where each language has its own linter and formatter.
Key takeaways
- Linting catches logic and style issues; formatting enforces a consistent style — both are needed for clean code.
- Running lint and format checks in CI turns best practices into an enforced gate, not just a suggestion.
- GitHub Actions makes it easy to add a lint job with just a few steps and a config file.
- Pinning tool versions and using branch protection are essential for consistent, enforced checks.
- Choose a linter/formatter that fits your project's language and community — Ruff for modern Python, ESLint/Prettier for JS.
- Troubleshoot by checking tool versions, paths, and configuration before blaming the code.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.