A running field guide to the traps that bite a Windows dev box day after day – the ones that are small on their own and expensive in aggregate. Each is written as a thirty-second diagnosis.
PATH drops tools mid-session, a hook calls python3 when only python exists, psycopg can’t find libpq, a background-process test fails only in automation, and a long command piped to tail looks hung at zero bytes. None is exotic and each wastes an hour the first time. This is the catalog, each entry as symptom, cause, fix, and – the part that pays off – how to avoid it next time. It is deliberately a list you append to; new traps join as they recur. If you want the one-time setup that prevents a different class of problem, see turning Windows into a dev workstation with one script; this page is the traps that still bite after the box is set up.Table of Contents
Why a Windows dev environment keeps biting
A Windows box running a Unix toolchain lives at a seam: POSIX tools expect a POSIX environment, and the real one underneath is Windows. Most of the day that seam is invisible. The traps below are the places it shows through – path translation, interpreter names, native library loading, process identity, pipe buffering. Written down, each becomes a pattern you recognize on sight instead of a fresh afternoon.
command not found for tools that are installed
- Symptom: mid-session,
grep,head,python,git, orghreportcommand not found– tools that worked minutes ago. - Cause: the shell’s
PATHgot trimmed or reset between invocations, dropping the MSYS/Git-Bash bin directories and your language toolchains. The binaries are still on disk; the shell just can’t see them. - Fix: prepend an explicit
PATHat the top of the command:export PATH="/mingw64/bin:/c/Program Files/Git/usr/bin:/usr/bin:$PATH". - Avoid: make that
exporta reflex at the start of any shell block that uses coreutils or a runtime. Don’t trust an inheritedPATHto still hold what it did last time.
python3: command not found, but python works
- Symptom: a git hook or script dies on
python3: command not found, though Python is installed andpythonruns fine. - Cause: the Windows Python installer lays down
python.exe(and sometimespython3.exe), but a Unix script hard-codespython3, which isn’t onPATH. A well-written hook probes for the interpreter and falls back – but only if one of the two names is reachable. - Fix: put the Python directory on
PATHso the probe resolves a name it can run. - Avoid: when you write a hook, probe for the interpreter (
PYBIN=python; command -v python >/dev/null || PYBIN=python3) instead of assuming a name; when you run something with a hook, confirm an interpreter is onPATHfirst.
libpq not found – psycopg won’t import
- Symptom: a Python process that uses
psycopgfails at import withno pq wrapper available ... libpq library not found, even inside the right virtual environment. - Cause:
psycopgneeds thelibpqclient library at runtime. On Windows it ships with PostgreSQL, in that install’sbindirectory, which isn’t onPATHby default. - Fix: add the Postgres
bintoPATHbefore running anything that imports psycopg:export PATH="/c/Program Files/PostgreSQL/16/bin:$PATH". - Avoid: bundle the Postgres
bininto the standardPATHprefix you use for database work, next to the virtual environment’s scripts. If a suite fails only when a hook runs it, this is a prime suspect – hooks inherit a thinner environment than your shell.
A background-process test fails only in automation
- Symptom: a test that launches a child process and checks whether its process id is “live” fails deterministically in CI or a sandbox – reporting zero live workers – while passing when you run it by hand.
- Cause: process-liveness checks on Windows often go through the OS process table, and a background job started under a nested or sandboxed shell may not resolve to a live id the way the test expects. The test is measuring an environment property, not your code.
- Fix: confirm it is pre-existing and environment-specific by running the same test against the unchanged main branch. If it fails there too, your change didn’t cause it – don’t chase it.
- Avoid: don’t gate a code change on an environment-dependent liveness test; mark such a test to skip off its platform, and verify it against the real way it will be invoked, not only your interactive shell.
A test that doesn’t use the caller’s real input shape measures the wrong thing.
A long command piped to tail looks hung at zero bytes
- Symptom: you run a long job and pipe it through
tailorheadto keep output small; the output stays empty for ages and you can’t tell whether it is working or wedged. - Cause: the pipe buffers. The downstream
tailemits nothing until the upstream closes or a full buffer flushes, so a healthy long job and a hung one look identical – both show nothing. - Fix and avoid: never pipe a long job through
tail/headfor progress. Redirect to a logfile and read that instead:long_job > run.log 2>&1 &.
A silent pipe is not evidence of a hang; it’s evidence you can’t see.
git -C /f/Dev/... silently acts on the wrong directory
- Symptom: a
git -C <path>command appears to succeed but acts on your current repository, not the one you named – or a safety guard blocks it unexpectedly. - Cause: MSYS path translation. A lowercase drive path like
/f/Dev/xisn’t always resolved as you expect and can fall back to the current directory; the Windows-styleF:/Dev/xis unambiguous. - Fix and avoid: use the
F:/Dev/...form forgit -C, and keep it to one simple git call per invocation so a silent fallback can’t hide inside a compound command.
Backslashes get mangled when you author code through a heredoc
- Symptom: code you wrote via a shell heredoc raises a strange, unrelated error later, somewhere else – a doubled backslash collapsed, a
\tbecame a real tab, a regex backreference turned into a control character. - Cause: the shell processes escapes before your content is ever written, silently corrupting anything with backslashes. Editors render control characters as nothing, so the file looks correct.
- Fix and avoid: author anything with backslashes – regexes, Windows paths, escape sequences – with a real file-writing tool, not a heredoc. To inspect for hidden damage, use
cat -Aor Python’srepr()rather than a normal read.
The short version
- Most Windows dev-environment pain lives at one seam: POSIX tools on a non-POSIX host. The traps cluster around
PATH, interpreter names, native libraries, process identity, and pipe buffering. - A
PATHprefix is the single highest-value reflex: it fixes “command not found,” thepython3miss, andlibpq not foundin one line. - Verify environment-dependent failures against main before you chase them – a test that fails the same way on unchanged code is not your bug.
- Never read progress through a buffering pipe; redirect to a log.
- This list is meant to grow. Each trap written down is a trap that only costs the first person, once.
Frequently Asked Questions
Why do my command-line tools disappear from PATH mid-session?
The shell’s PATH can be trimmed or reset between invocations on a Windows box, dropping the Git-Bash/MSYS bin directories and your language runtimes. The tools are still installed; the current shell just can’t find them. Prepending an explicit PATH at the start of the command restores them, and doing that by habit for any shell block that uses coreutils or a runtime avoids the surprise entirely.
Should a script call python or python3 on Windows?
Neither name is guaranteed. The Windows installer always provides python.exe and sometimes python3.exe, so a script hard-coded to either can miss. The portable pattern is to probe – try python, fall back to python3 – and, when running something that has a hook, make sure at least one of those names is on PATH so the probe resolves.
Why does psycopg fail with “libpq library not found” only inside a hook or CI?
psycopg loads the native libpq client library at runtime, which on Windows ships inside the PostgreSQL install’s bin directory and is not on PATH by default. Your interactive shell may happen to have it; a git hook or CI job runs with a thinner environment and does not. Adding the Postgres bin to PATH before the process starts fixes it in both places.
Why does my process test pass locally but fail in CI?
Because it is testing a property of the environment, not of your code. Process-liveness checks that resolve a child process id through the Windows process table can behave differently inside a nested or sandboxed shell, so a test that spawns a background job and counts it as “live” can fail deterministically in automation while passing in your shell. Confirm by running the same test on the unchanged main branch.
Is this the same as setting up a Windows dev workstation?
No – it is the other half. One-time setup (long path support, disabling sleep mid-job, telemetry, sensible defaults) prevents a class of problems and is covered in the Windows dev-workstation setup script. This page is the recurring traps that remain after the box is configured: the ones that come from running a Unix toolchain on Windows, not from the machine’s settings.
How do I add a trap to this guide?
Capture it in the same shape as the entries here – symptom, cause, fix, avoid – the moment it costs you time, while the details are exact. The value of a field guide like this is cumulative: a trap written down once stops being a recurring tax and becomes a lookup.