IndentationError: Fix Bad Indents, Tabs, and Empty Blocks
IndentationError means a line's indent doesn't match its block.
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
- ✓Python basics: if statements, for loops, and function definitions
- ✓Running scripts with python3 and reading tracebacks in the terminal
- ✓A code editor where you can show whitespace and set indent style
- The fix is local: dedent or indent the flagged line to its block's level, replace tabs with four spaces, and put
passunder any empty if, def, or for — then rerun to confirm. - Read the caret first: the traceback names the file, line, and column of the exact token the parser rejected, and the cause sits within two lines of it.
- Reveal invisible characters with
cat -A file.py(tabs show as^I) orpython3 -m tabnanny file.pywhen the line looks fine but still fails. - Prevent repeats with
.editorconfigdeclaring spaces plusruff checkandruff format --checkin CI, so mixed indentation fails the build instead of the deploy.
Think of a cookbook where sub-steps sit indented under their main step. Python reads your code the same way: indentation says which lines belong to which instruction. An unexpected indent is a sub-step with no parent above it, an empty block is a step with no instructions beneath it, and mixed tabs and spaces are two cooks using different rulers so nothing lines up. The parser stops and points at the confusing line. Align each line under its parent, fill every step, and use one ruler.
You press run and Python refuses before executing a single statement: IndentationError: unexpected indent. Nothing ran, no data changed, no logic was even evaluated — the parser rejected your file's shape. For newcomers this feels personal, as if the language is grading neatness. It isn't. Indentation is Python's grammar for grouping, doing the job braces do elsewhere, so a misaligned line genuinely changes — or destroys — the program's meaning.
This error family fires in a handful of repeatable situations. A line sits deeper than any open block, and the parser can't attach it anywhere. A dedent lands at a level no open block owns. A colon-header like if, def, or for has no body beneath it, leaving the suite empty. Or tabs and spaces mix in one file, and the parser — unable to tell which visual column a tab meant — raises TabError rather than guess.
The good news: every variant points at itself. The traceback carries a caret marking the exact line and column, and the message names the precise complaint. This article teaches you to read that caret, diagnose invisible whitespace with cat -A and tabnanny, fix each variant at the flagged line, and set up editorconfig plus ruff so your team stops shipping these entirely.
Unexpected Indent and Unindent: the Block the Parser Rejected
An unexpected indent means a line stepped right without any open block inviting it in. After a plain statement like x = 1, the parser expects the next line at the same level or a dedent — so a four-space hop on the following line has nowhere to attach, and compilation dies naming that line. The mirror image, an unexpected unindent, means a line stepped left to a level no open block owns: it left its suite but didn't land on any enclosing one. Both are shape errors, and both fire before a single statement runs.
beginners hit the first variant by pasting. A snippet copied from inside a function or a chat window carries its original leading spaces into a context one level shallower, and the pasted lines float right of everything around them. The second variant comes from deleting or misaligning a block header: the body outlives its if or for, and the orphan lines dedent into the void. In both cases the code around the error is innocent — only the flagged line's level disagrees with the structure.
The repair is mechanical. Read the caret's line and column, compare the flagged line's leading whitespace with its siblings, and move it to the level it belongs at. When the line looks correct, suspect the two lines above: a missing colon or an unclosed parenthesis changes what the parser expects, making a rightly-indented line wrong by context. Recompile with py_compile after each nudge; the loop is seconds long and always converges.
Empty Suite After a Colon: if, def, and for Demand a Body
Every compound header demands a suite — at least one statement nested beneath it. When if, for, while, def, class, or with ends with a colon and nothing follows at a deeper level, the parser reports expected an indented block and stops. This isn't Python being fussy about style; a header with no body is structurally incomplete, like a bookshelf bracket with no shelf. The grammar requires the suite the way a sentence requires a verb.
You'll meet this while sketching. You write the if first intending to fill the body after lunch, or you stub three functions and implement one, or a loop's body gets deleted during an edit leaving the header behind. The whole module then refuses to import — not just your function — because compilation is all-or-nothing. One bare header poisons every test in the file, which is why this error often arrives disguised as a mysterious collection failure.
The placeholder is `pass`: a statement that does nothing and compiles to nothing, existing purely to satisfy the suite requirement. Put it under the header with a TODO comment naming the ticket, and the file imports while the work stays visibly pending. When the real body lands, the diff touches one hunk, reviewers see exactly what changed, and no other test ever noticed the gap.
pass with a ticket number restored 200 tests while the real handler was still unwritten.TabError: the Subclass That Fires on Mixed Tabs and Spaces
TabError is IndentationError's stricter child: it fires when tabs and spaces mix in a file's indentation and the parser can't determine the intended block level. A tab doesn't mean four spaces or eight — it means 'advance to the next tab stop', a width set by your editor, not your code. Two editors rendering the same file can disagree about which block a tab-indented line belongs to, so Python refuses to guess and raises instead. That refusal is a safety feature wearing an error's clothes.
The treacherous part is how innocent mixed files look. In most fonts and review tools, a tab renders at exactly the width that makes the code appear aligned — the diff looks perfect while the bytes contain two different rulers. And there's a worse outcome than the error: when the mixing is consistent enough to tokenize, the file compiles but attaches statements to the wrong block, silently changing behavior with zero visible diff. The crash is the kind outcome; silent misgrouping is the cruel one.
The remedy is uniformity enforced by tools, not eyes. Convert the file to spaces once, turn on render-whitespace so tabs glow on sight, and declare spaces in .editorconfig so every editor agrees. Because TabError subclasses IndentationError, existing except IndentationError handling already covers it — but you should never be catching this in production code. Mixed indentation is a file-construction defect, fixed at the editor and the commit hook, not at runtime.
Invisible Whitespace: Diagnose Mixed Indents With cat -A
Most indentation bugs are invisible by design: your editor renders tabs, spaces, and even non-breaking spaces as identical gaps. Diagnosis therefore means making the invisible visible. cat -A is the fastest lens — it prints tabs as ^I, marks every line end with $, and exposes non-breaking spaces as M- sequences, so a mixed-indent region shows up as ^I lines sitting among space lines. Pair it with sed -n '40,60p' to frame the neighborhood around the caret's line instead of drowning in a thousand-line dump.
The stdlib offers a second opinion: python3 -m tabnanny walks a file and reports ambiguous indentation without executing anything, which is ideal for pre-commit checks and for sweeping a whole directory after an incident. For the programmatic route, on a line exposes every character honestly — tabs, trailing spaces, and exotic Unicode gaps all appear as escapes — and swapping spaces for dots makes levels countable at a glance.repr()
Build the habit of confirming before editing. Frame the caret line with cat -A, identify the exact intruder character, fix that line, and recompile. The alternative — re-indenting whole regions by feel — usually moves the error to a new line while the original tab sits untouched two lines up. Bytes first, edits second, every time.
cat -A frame would have ended it.repr() before moving anything..editorconfig, Ruff, and Black: Prevention That Runs Itself
Prevention beats diagnosis, and for indentation it comes in three layers that reinforce each other. The .editorconfig file declares the contract — indent_style = space, indent_size = 4, trim_trailing_whitespace = true — and every conforming editor enforces it silently as people type. No meetings, no wiki page; the file sits at the repo root and the whole team types identically from the next commit on.
The second layer is the formatter. Ruff's formatter (or Black, if that's your house standard) rewrites layout deterministically, so ruff format on save or on commit erases ragged indentation before review. The third layer is the gate: ruff check --select E,W plus ruff format --check in CI rejects any file that drifted, and a pre-commit hook runs both locally so the failure arrives in seconds on the author's machine instead of minutes into a deploy pipeline.
Teams resist this as bureaucracy until their first whitespace incident, then adopt it in an afternoon. The economics are lopsided: one CI job and one config file against crash-looped workers, rejected payments, and a rollback performed under pressure. Make the build care about whitespace and humans never have to again.
ruff format --check names the file and line while the author still has context — the cheapest fix this error family has.Caret Reading: the Line and Column the Traceback Flags
The traceback for an indentation failure is unusually generous: it carries the filename, the line number, the offending line's text, and a column offset rendered as a caret. That caret marks the exact token where the parser's expectations broke — the over-indented statement, the dedent that matched nothing, the unindented line where a suite should start. Your first move is always to open that file at that line and compare its leading whitespace with the block it claims to join.
Read the message text alongside the caret. Unexpected indent means the line stepped right with no open header inviting it; expected an indented block means a colon-header above has no suite and the caret sits where the body should begin; unindent does not match any outer level means the dedent skipped past every enclosing block. Each phrase prescribes its repair: dedent the line, add the body, or re-seat the dedent at a real level.
One caution keeps this fast: the caret marks where the parser gave up, which occasionally trails the true cause. A missing colon on the line above, or an unclosed bracket two lines up, shifts the parser's expectations so a correctly indented line reads as wrong. If the flagged line looks flawless, inspect the two lines above it for unclosed syntax before touching anything else. Caret first, context second, edits last.
The Tab-Indented Hotfix That Crash-Looped 12 Checkout Workers for 9 Minutes
TabError: inconsistent use of tabs and spaces in indentation (billing.py, line 214) — a file touched only by a one-line hotfix merged 20 minutes earlier. Roughly 1,400 payment attempts were rejected over 9 minutes while the team rolled back to the previous release.billing.py was pasted from a chat window carrying tab indentation into a space-indented file, mixing two indent styles in one module. At the next rolling restart, all 12 gunicorn workers failed at import with TabError: inconsistent use of tabs and spaces in indentation on line 214 — the tab-indented retry block the hotfix had added. Because the import failed, every worker exited, the supervisor restarted them, and the restart loop held for 9 minutes during peak checkout, rejecting roughly 1,400 payment attempts before the previous release was rolled back.billing.py to spaces with the editor's convert-indentation command and re-ran python3 -m py_compile billing.py, confirming a clean compile in under a minute; the 12 workers were restarted and the 9-minute crash-loop ended. Second, an .editorconfig was committed declaring indent_style = space and indent_size = 4 for *.py, and every editor on the team enabled its EditorConfig plugin. Third, CI gained ruff check --select E,W and ruff format --check as merge gates plus a pre-commit hook, so the next mixed-indent commit fails locally in seconds instead of crash-looping 12 workers. A repo-wide grep -rP '\t' sweep converted the 3 other tab-carrying files the same day.- Whitespace that renders identically can tokenize differently — review tooling must check bytes, not pixels, which means formatter gates in CI, not eyeballs.
- A crash at import time has maximum blast radius: one bad file takes all 12 workers, so compile checks belong on every pull request, not just releases.
- Editor defaults are infrastructure: standardize them in
.editorconfigon day one, because two rulers in one repo always ends in an incident.
python3 -m py_compile payments/worker.py. The output names the file, line, and column with a caret under the offending token — d = "x" flagged on line 42 means the parser choked exactly there. Fix that line first; the cause is almost always within two lines of the caret, not in the region you suspect.cat -A payments/worker.py | sed -n '35,50p'. Tabs show as ^I, line ends as $, and non-breaking spaces as M- sequences. If the flagged line uses ^I while its neighbors use spaces, convert the file with sed -i 's/\t/ /g' (or your editor's convert command) and recompile.python3 -m tabnanny payments/worker.py. Tabnanny reports ambiguous indentation it finds without running anything. For repo-wide sweeps use grep -rnP '\t' --include='*.py' . to list every file containing a tab, then convert each one and add a formatter so they stay clean.git diff --check flags whitespace errors (trailing spaces, tab/space mixing) in the working tree, and git diff -U2 | cat -A | head -40 shows the changed lines with invisible characters visible. If the culprit arrived in the last commit, git log -1 --format=%H plus a revert of that hunk is faster than hand-editing.ruff check --select E,W,TAB payments/ for indentation rules and ruff format --check payments/ for layout drift. Wire both into CI and a pre-commit hook. A red build names the file and line while the author still has context — infinitely cheaper than a worker crash-loop at rollout.python3 -m py_compile payments/worker.py && python3 -c "import payments.worker; print('import ok')". The compile step catches shape errors; the import step catches anything the header structure broke (like a decorator now attached to the wrong def). Both must pass.| File | Command / Code | Purpose |
|---|---|---|
| unexpected_indent_demo.py | bad = "x = 1\n y = 2\n" | Unexpected Indent and Unindent |
| empty_block_pass.py | def todo_later(): | Empty Suite After a Colon |
| taberror_subclass_demo.py | mixed = "if True:\n x = 1\n\tx = 2\n" | TabError |
| whitespace_scan.py | sample = "def f():\n x = 1 \n\ty = 2\n" | Invisible Whitespace |
| indent_consistency_check.py | def check_indent(source): | .editorconfig, Ruff, and Black |
| caret_reading_demo.py | bad2 = "def f():\nprint('oops')\n" | Caret Reading |
Key takeaways
Common mistakes to avoid
5 patternsPasting a snippet one level too deep and trusting your eyes
IndentationError: unexpected indent on a line that looks perfectly aligned, because the block above it sits one level left of where you pasted.python3 -m py_compile file.py. If the caret points at a line that looks fine, check the line above — a missing colon or parenthesis there shifts the parser's expectations.Leaving an if/def/for body empty while sketching structure
expected an indented block the moment you save or import — the file can't even compile, so every test in the module errors, not just yours.pass under the empty header now, with a TODO comment naming the ticket. When the real body lands, the diff touches exactly one hunk and reviewers see the intent.Mixing tabs and spaces across edits from different editors
expandtabs or your editor's 'convert indentation to spaces', then enable render-whitespace so tabs glow. Add .editorconfig with indent_style = space so it never recurs.Re-indenting whole regions instead of reading the caret
cat -A file.py | sed -n '40,60p' around the flagged line and look for ^I (tab) versus spaces. Then fix the exact line the caret names instead of re-indenting the whole neighborhood.Skipping formatter checks in CI because 'it's just whitespace'
ruff check --select E,W and ruff format --check to CI, plus a pre-commit hook that runs them. The red build names the file and line; the author fixes it before review starts.Interview Questions on This Topic
Why does Python raise IndentationError instead of just running the code?
: headers. A line indented deeper than any open block raises unexpected indent; a dedent that matches no open level raises unindent errors; a header with no suite raises expected-indented-block. All variants fire at compile time, before any statement executes.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
That's Errors. Mark it forged?
6 min read · try the examples if you haven't