docs java javascript python c / c++ c# reading reports
How to profile Python with py-spy
py-spy is a sampling
profiler that reads a running Python process from the outside: no code
changes, no imports, negligible overhead, safe against production. It's the right
default for “why is this service slow” — cProfile answers a
different question (deterministic per-call counts) at a much higher cost.
Install and capture
pip install py-spy
# profile a command end-to-end, exporting speedscope format
py-spy record --format speedscope -o profile.speedscope -- python app.py
# or attach to something already running (the production move)
py-spy record --format speedscope -o profile.speedscope --pid <pid> --duration 60
Useful flags: --idle includes threads waiting on I/O (turn it on when the
app is slow but CPU is low — the answer is usually blocking calls),
--native samples into C extensions, and --subprocesses
follows worker processes (gunicorn, multiprocessing). On Linux, attaching to a running
pid may need sudo (or --cap-add SYS_PTRACE on the container);
launching the command yourself does not.
py-spy top --pid <pid> is the zero-commitment first look — a live,
htop-style view of where time is going right now.
In JetBrains PyCharm
PyCharm Professional's Run → Profile button runs cProfile and opens a
.pstat snapshot — good for interactive exploration inside the IDE. For a
portable sampling profile (and the file flamelens will take), drive py-spy from
PyCharm's terminal:
- Open the integrated terminal (Alt+F12) — it activates your
project's virtualenv automatically — and
pip install py-spyonce. - Either launch under py-spy right there:
py-spy record --format speedscope -o profile.speedscope -- python main.py - …or run your app normally with the Run button, then attach: find the pid in the
Run console's first line (or
py-spy toplists candidates) andpy-spy record --format speedscope -o profile.speedscope --pid <pid> --duration 60 - The
profile.speedscopefile lands in your project root.
Tip: add py-spy as an External Tool (Settings → Tools → External Tools)
if you do this often — arguments
record --format speedscope -o profile.speedscope --pid $Prompt$.
In VS Code
- Open the integrated terminal (Ctrl+`) with your virtualenv
selected (the Python extension activates it), and
pip install py-spy. - Launch under py-spy, or attach to a debugged/running process by pid exactly as
above — the commands are identical:
py-spy record --format speedscope -o profile.speedscope -- python app.py - For repeatability, wire it as a task in
.vscode/tasks.json:{ "version": "2.0.0", "tasks": [{ "label": "profile with py-spy", "type": "shell", "command": "py-spy record --format speedscope -o profile.speedscope -- python app.py" }] }
Now analyze it. Take profile.speedscope to the
flamelens terminal — auth <email> →
verify <code> → scan init. A free tuning session
(five scans) every month. Running a JVM service too?
JFR works too.
Why there's no “scan live” for Python
For Java we can attach to a running service over JMX, and for Node over the inspector
port, because both runtimes ship a remote control protocol. CPython doesn't.
py-spy works by reading another process's memory directly through
process_vm_readv / vm_read / ReadProcessMemory
and walking the interpreter's stack structures itself. That is a local operation by
construction — there is no port to connect to and nothing to expose, even if you wanted
to.
Typing scan live against a Python service will tell you exactly this rather
than pretend. The two real options are a py-spy file you upload, or an agent that runs
py-spy for you on a schedule.
Continuous sampling with an agent ENTERPRISE
The agent runs on the same host as your service — which is what py-spy needs anyway — and connects out to us over HTTPS. Nothing inbound, no credentials held by flamelens, and a profile every N minutes diffed against the last one:
agent new prod-worker-1
# → prints a token, ONCE. store it like a password.
schedule new worker 1234 python 30 60
# → sample pid 1234 for 60s, every 30 minutes
For Python the schedule's target is a pid (or anything your agent
script can resolve to one — a pidfile path, a container name, a systemd unit) rather
than a host:port, since that's what py-spy takes. The agent's recorder
step is one line:
py-spy record --format speedscope -o "$out" --pid "$TARGET" --duration "$secs"
Two operational notes worth getting right before you deploy it. First,
ptrace permissions: attaching to a process you don't own needs
sudo, or CAP_SYS_PTRACE on the agent — in Kubernetes that's
securityContext.capabilities.add: ["SYS_PTRACE"], and a sidecar
additionally needs shareProcessNamespace: true to see the app container's
pids at all. Second, pids change on redeploy, so resolve the target
each cycle (pgrep -f gunicorn, a pidfile, or
py-spy dump to confirm you found a Python process) instead of pinning the
pid you found once.
The full polling loop — including the JAVA/NODE/PYTHON branch — is on the JavaScript page; only the recorder line differs per runtime. Samples land in your current tuning session like any other scan.
What makes a good profile
- Attach during the pain. 60 seconds of the slow request pattern beats ten minutes of idle.
- Reach for
--idleearly. Python services are more often blocked (database, HTTP, locks — and the GIL) than CPU-bound; without idle threads the profile can look innocently empty. - Profile workers, not just the master —
--subprocessesfor gunicorn/uvicorn/celery, or attach to one worker pid directly.