isort
isort rewrites the import block at the top of a Python file into a consistent order. It splits imports into sections (future, standard library, third party, first party, local), sorts within each section, separates plain import statements from from-imports, merges duplicate from-imports of the same module, and wraps anything that runs past your line length according to one of twelve output styles. It knows which modules are standard library from a per-version list baked into the package, and it works out which are yours from src_paths and the location of your config file. You run it as a command (isort . rewrites in place, isort --check-only . just fails), as a pre-commit hook, from an editor plugin, or as a Python library through isort.code() and isort.check_code(). It does one job and does not touch anything below the imports, which is why it is normally paired with a formatter rather than used instead of one.
isort is the tool that defined how Python import blocks are supposed to look, and it is still the most configurable implementation of that idea by a wide margin. On a new project reach for ruff's I rules first, and keep isort for repos that already depend on it or that need grouping behavior ruff does not expose.
Use it if
- Your project already standardizes on it, your CI already runs isort --check-only, and every contributor's editor is wired to it. Switching tools costs a repo-wide diff that nobody asked for
- You need grouping control beyond alphabetical: custom sections for an internal namespace, forced_separate for a subpackage, known_first_party overrides, or per-module placement decided at runtime through isort.place_module()
- You want a Python API rather than a CLI: isort.code() returns sorted source as a string and isort.check_code() returns a boolean, which is what you want when import sorting is a step inside your own codemod or linter
- You need a git hook that only inspects staged files, which isort ships as isort.hooks.git_hook rather than leaving you to script it
- Your files need partial control: # isort: split to force a section break, # isort: off and # isort: on around a block that must not move, and # isort: skip on a single line
- You are picking tools for a new project. Ruff implements the same rules as its I ruleset, reads a compatible [tool.ruff.lint.isort] config, runs in a fraction of the time, and is already in the binary doing your linting and formatting. isort remains the reference implementation that ruff matches, which is an odd position for the tool you are being asked to install separately
- You are not prepared to configure it against your formatter. Left at defaults it wraps imports one way and black or ruff format wraps them another, so each tool undoes the other and pre-commit loops until it gives up. profile = "black" exists because six interacting settings had to be aligned by hand, and the equivalent for other formatters is on you
- You want a small configuration surface. There are roughly a hundred settings; multi_line_output alone has twelve modes, and it interacts with force_grid_wrap, use_parentheses, include_trailing_comma, split_on_trailing_comma, and line_length. Getting a specific look you have in your head is genuinely an afternoon unless a profile already matches it
- You are counting on it to clean up imports. It sorts and groups; it will not delete an unused import, add a missing one, or tell you about a circular one. You are still installing a linter next to it, and that linter can very likely already sort
- Your import block has a deliberate order. A monkeypatch applied before the module it patches, gevent patching before anything touches sockets, matplotlib.use() before pyplot, or django.setup() before models are all broken by alphabetical sorting. isort cannot see the dependency, --atomic only verifies the result still parses rather than still works, and it is off by default
- You want removals to be gentle. 8.0.0 deleted the setuptools plugin and the legacy finder logic outright, and the 9.0.0 alphas have already removed the code paths for deprecated options. Output also shifts between releases, so an unpinned upgrade can turn --check-only red on code nobody touched
Setup reality
pip install isort needs Python 3.10 or newer and pulls no required dependencies; colored diffs need isort[colors] for colorama. Configuration goes in pyproject.toml under [tool.isort] with underscored keys, and it also reads setup.cfg, .isort.cfg, and tox.ini. The part that catches monorepos is discovery: isort walks up from each file it is given and stops at the first config it finds, so two subdirectories can be sorted under two different rule sets without any warning. Pass --settings-path to force one, or --resolve-all-configs when you actually want per-directory rules. If you run black or ruff format, set profile = "black" before your first commit or the two tools will rewrite each other's wrapping forever. First-party detection is a heuristic over src_paths, which defaults to the config file's directory plus src, so a package laid out any other way gets classified as third party and your imports land in the wrong section until you set known_first_party. isort does not respect .gitignore unless you pass --gitignore or set skip_gitignore, so generated code and vendored trees need explicit skip or extend_skip entries. In CI, --check-only exits 1 and prints nothing useful on its own, so always add --diff. Pin the version in both CI and pre-commit, since sorting output is not frozen across releases. And note that python setup.py isort no longer exists: the setuptools plugin was removed in 8.0.0.
Patterns
Rewrite imports across a projectsort-in-place
isort .
isort src/ tests/
isort mymodule.py another.py
# preview instead of writing
isort --diff .
# only apply if the result still parses
isort --atomic .isort . rewrites files with no backup, so commit first the very first time. --atomic re-parses the output and refuses to write when it would introduce a syntax error; it is off by default because it cannot parse code written for a different Python version than the interpreter running isort. It checks that the file still parses, not that it still behaves.
Fail the build on unsorted importscheck-in-ci
isort --check-only --diff .
# exit 0 everything already sorted
# exit 1 at least one file would change
# speed it up on a large repo
isort --check-only --diff --jobs 8 .--check-only alone tells a contributor that something is wrong without saying what, so always pair it with --diff and let CI print the exact patch. Pin the isort version in the same file that installs it: sorting output is not stable across releases, and a floating version turns a green build red without a code change.
Stop isort and black from fightingmatch-your-formatter
# pyproject.toml
[tool.isort]
profile = "black"
line_length = 88
# what the profile actually sets:
# multi_line_output = 3
# include_trailing_comma = true
# force_grid_wrap = 0
# use_parentheses = true
# ensure_newline_before_comments = trueWithout this, isort wraps a long from-import one way, black rewraps it another, and pre-commit reports both hooks modifying files on every run until it hits its retry limit. Other profiles ship too (django, google, attrs, pycharm, plone), and if you change line_length you must change it in both tools or the disagreement comes straight back.
The settings most projects actually needconfigure-in-pyproject
[tool.isort]
profile = "black"
line_length = 88
src_paths = ["src", "tests"]
known_first_party = ["myapp"]
extend_skip = ["migrations", "vendor"]
extend_skip_glob = ["*/_generated/*"]
skip_gitignore = true
float_to_top = false
lines_after_imports = 2
combine_as_imports = trueKeys use underscores in TOML and dashes on the command line. isort searches upward from each target file and stops at the first config it finds, so in a monorepo different directories can silently get different rules; use --settings-path to force one, or --resolve-all-configs when per-directory rules are the point. skip_gitignore is off by default, which surprises people whose generated code keeps getting reformatted.
Get your own package out of the third-party sectionfix-first-party-detection
[tool.isort]
src_paths = ["src", "tests"]
known_first_party = ["myapp", "myapp_plugins"]
known_local_folder = ["conftest"]
known_third_party = ["custom_vendored_lib"]
# see how isort classifies one module
python -c "import isort; print(isort.place_module('myapp.core'))"Classification is a heuristic: isort looks for the module under src_paths, which defaults to the config file's directory plus src. A flat layout, a namespace package, or an editable install in a nonstandard location all defeat it, and the symptom is your own imports sorted in with requests and numpy. place_module() tells you the verdict for a single name, which is faster than bisecting a diff.
Exempt files, blocks, and single linesskip-code
import sys
import gevent.monkey; gevent.monkey.patch_all() # isort: skip
# isort: off
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# isort: on
from . import late_module # isort: skip
# first line of a file isort must never touch:
# isort: skip_fileThis is the escape hatch for imports with side effects, which isort has no way to detect on its own. # isort: off and # isort: on bracket a region; # isort: skip applies to one statement; # isort: skip_file must appear in the file's own comments and takes the whole file out. Use # isort: split when you only want a section boundary rather than an exemption.
Sort staged files before they landpre-commit-hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/PyCQA/isort
rev: 8.0.1
hooks:
- id: isort
args: ["--profile", "black", "--filter-files"]Pin rev to an exact version and bump it on purpose; pre-commit autoupdate crossing a major will reformat the whole repo in someone's unrelated pull request. --filter-files makes isort apply its own skip settings to the file list pre-commit hands it, which it otherwise ignores because those files were named explicitly. If you also run black, list isort first so black gets the final say on wrapping.
Pick how long from-imports breakchoose-a-wrap-style
# mode 3, vertical hanging indent (what profile="black" uses)
from third_party import (
lib1,
lib2,
lib3,
)
# force_single_line = true: one import per line, zero merge conflicts
from third_party import lib1
from third_party import lib2
# force_sort_within_sections = true: mixes import and from-import
import os
from pathlib import Path
import sysmulti_line_output takes values 0 through 11 and the docs render every one of them, which is the fastest way to choose. force_single_line is worth considering on a repo with many contributors: it produces longer files but a line-level diff, so two people adding imports to the same module stop conflicting. Changing any of these is a whole-repo commit, so record its hash in .git-blame-ignore-revs.
Sort code from inside your own toolinguse-the-python-api
import isort
sorted_src = isort.code("import b\nimport a\n")
isort.file("pythonfile.py") # rewrites in place
if not isort.check_code(src, show_diff=True):
raise SystemExit("imports are not sorted")
cfg = isort.Config(profile="black", known_first_party=["myapp"])
isort.code(src, config=cfg)
isort.place_module("myapp.core") # -> 'FIRSTPARTY'isort.code() takes and returns a string and never touches disk, which makes it the right entry point inside a codemod or a custom linter. Build an isort.Config once and reuse it: constructing one re-reads and re-resolves configuration files, so creating it per file in a loop is a measurable cost on a large tree.
Inject an import into every fileadd-required-imports
isort --add-import 'from __future__ import annotations' src/
# permanently, in config
[tool.isort]
add_imports = ["from __future__ import annotations"]
# and the reverse
# isort --rm-import 'from six import string_types' src/This is the tidiest way to roll out a from __future__ import across a codebase, since isort also places it in the correct section. It adds the import to every file it processes, including empty __init__.py files and scripts where it is pointless, so scope the run with skip settings or a path argument rather than pointing it at the repo root.
Pull stray mid-file imports into the headerfloat-imports-to-top
isort --float-to-top src/
# in config
[tool.isort]
float_to_top = trueHandy on legacy code where imports drifted into function bodies and conditionals, and it respects # isort: off and # isort: skip while doing it. Be careful: an import placed inside a function is sometimes deliberate, either to break a circular dependency or to defer an expensive module, and hoisting it turns a working file into an ImportError at startup. Run it once, read the diff, do not leave it on.
See the resolved config, or move the job to ruffdebug-or-migrate
isort --show-config # every setting after all files are merged
isort --show-files . # exactly which files would be processed
isort --verbose --diff file.py
# equivalent behavior inside ruff
# ruff check --select I --fix .
#
# [tool.ruff.lint.isort]
# known-first-party = ["myapp"]
# combine-as-imports = true--show-config is the first thing to run when isort behaves differently in CI than on your laptop; nine times out of ten a different config file was discovered. If the resolved settings turn out to be plain (a profile, first-party names, a couple of skips), that config maps almost directly onto ruff's isort section and you can drop a dependency.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ruff | PyPI | You want import sorting, linting, and formatting from one fast binary and can accept a config section instead of isort's full option list. |
| reorder-python-imports | PyPI | You prefer one opinionated style with almost no configuration, and you want each from-import split onto its own line so merges never conflict. |
| usort | PyPI | You need sorting that is careful about not reordering imports with side effects, which is the failure mode isort will not protect you from. |
| autoflake | PyPI | Your actual problem is unused and duplicate imports rather than their order, which isort deliberately does not address. |