[{"data":1,"prerenderedAt":1111},["ShallowReactive",2],{"page-\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fattaching-debugpy-to-a-container\u002F":3},{"id":4,"title":5,"body":6,"description":1077,"extension":1078,"meta":1079,"navigation":264,"path":1107,"seo":1108,"stem":1109,"__hash__":1110},"content\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fattaching-debugpy-to-a-container\u002Findex.md","Attaching debugpy to a Container",{"type":7,"value":8,"toc":1066},"minimark",[9,18,23,37,41,44,79,93,96,239,245,248,296,461,465,470,567,571,578,691,697,704,708,711,729,732,736,791,795,798,810,823,832,842,852,856,862,868,893,1023,1027,1056,1062],[10,11,12,13,17],"p",{},"Artefacts answer most CI questions, but not all of them. When a failure depends on state that only exists mid-run — the contents of a connection pool, the value a third-party client returned, the branch a condition took — nothing short of a debugger will do. ",[14,15,16],"code",{},"debugpy"," provides one over a socket, which is exactly what a container can offer.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,34],"ul",{},[27,28,29,33],"li",{},[30,31,32],"strong",{},"debugpy 1.8+"," in the image, and an editor that speaks the Debug Adapter Protocol (VS Code, PyCharm, nvim-dap).",[27,35,36],{},"A container you can start with a published port; this is a deliberate debugging run, not a normal CI job.",[19,38,40],{"id":39},"solution","Solution",[10,42,43],{},"Start the test session under the debugpy launcher, listening inside the container and waiting for the editor:",[45,46,51],"pre",{"className":47,"code":48,"language":49,"meta":50,"style":50},"language-console shiki shiki-themes github-light github-dark","$ docker run --rm -it -p 127.0.0.1:5678:5678 -v \"$PWD:\u002Fsrc\" -w \u002Fsrc \\\n    ghcr.io\u002Facme\u002Fci-python@sha256:6b1c... \\\n    python -m debugpy --listen 0.0.0.0:5678 --wait-for-client \\\n      -m pytest -q -x tests\u002Ftest_orders.py::test_total\n","console","",[14,52,53,61,67,73],{"__ignoreMap":50},[54,55,58],"span",{"class":56,"line":57},"line",1,[54,59,60],{},"$ docker run --rm -it -p 127.0.0.1:5678:5678 -v \"$PWD:\u002Fsrc\" -w \u002Fsrc \\\n",[54,62,64],{"class":56,"line":63},2,[54,65,66],{},"    ghcr.io\u002Facme\u002Fci-python@sha256:6b1c... \\\n",[54,68,70],{"class":56,"line":69},3,[54,71,72],{},"    python -m debugpy --listen 0.0.0.0:5678 --wait-for-client \\\n",[54,74,76],{"class":56,"line":75},4,[54,77,78],{},"      -m pytest -q -x tests\u002Ftest_orders.py::test_total\n",[10,80,81,84,85,88,89,92],{},[14,82,83],{},"--listen 0.0.0.0:5678"," accepts a connection from outside the container's network namespace; ",[14,86,87],{},"-p 127.0.0.1:5678:5678"," publishes it to the host loopback only, which keeps the debug port off the network. ",[14,90,91],{},"--wait-for-client"," blocks until the editor attaches, so nothing — including imports — runs before the breakpoints are registered.",[10,94,95],{},"The editor side needs the path mapping, which is the setting that most often goes wrong:",[45,97,101],{"className":98,"code":99,"language":100,"meta":50,"style":50},"language-json shiki shiki-themes github-light github-dark","{\n  \"name\": \"Attach to container pytest\",\n  \"type\": \"debugpy\",\n  \"request\": \"attach\",\n  \"connect\": { \"host\": \"127.0.0.1\", \"port\": 5678 },\n  \"pathMappings\": [\n    { \"localRoot\": \"${workspaceFolder}\", \"remoteRoot\": \"\u002Fsrc\" }\n  ],\n  \"justMyCode\": false\n}\n","json",[14,102,103,109,125,137,149,180,189,216,222,233],{"__ignoreMap":50},[54,104,105],{"class":56,"line":57},[54,106,108],{"class":107},"sVt8B","{\n",[54,110,111,115,118,122],{"class":56,"line":63},[54,112,114],{"class":113},"sj4cs","  \"name\"",[54,116,117],{"class":107},": ",[54,119,121],{"class":120},"sZZnC","\"Attach to container pytest\"",[54,123,124],{"class":107},",\n",[54,126,127,130,132,135],{"class":56,"line":69},[54,128,129],{"class":113},"  \"type\"",[54,131,117],{"class":107},[54,133,134],{"class":120},"\"debugpy\"",[54,136,124],{"class":107},[54,138,139,142,144,147],{"class":56,"line":75},[54,140,141],{"class":113},"  \"request\"",[54,143,117],{"class":107},[54,145,146],{"class":120},"\"attach\"",[54,148,124],{"class":107},[54,150,152,155,158,161,163,166,169,172,174,177],{"class":56,"line":151},5,[54,153,154],{"class":113},"  \"connect\"",[54,156,157],{"class":107},": { ",[54,159,160],{"class":113},"\"host\"",[54,162,117],{"class":107},[54,164,165],{"class":120},"\"127.0.0.1\"",[54,167,168],{"class":107},", ",[54,170,171],{"class":113},"\"port\"",[54,173,117],{"class":107},[54,175,176],{"class":113},"5678",[54,178,179],{"class":107}," },\n",[54,181,183,186],{"class":56,"line":182},6,[54,184,185],{"class":113},"  \"pathMappings\"",[54,187,188],{"class":107},": [\n",[54,190,192,195,198,200,203,205,208,210,213],{"class":56,"line":191},7,[54,193,194],{"class":107},"    { ",[54,196,197],{"class":113},"\"localRoot\"",[54,199,117],{"class":107},[54,201,202],{"class":120},"\"${workspaceFolder}\"",[54,204,168],{"class":107},[54,206,207],{"class":113},"\"remoteRoot\"",[54,209,117],{"class":107},[54,211,212],{"class":120},"\"\u002Fsrc\"",[54,214,215],{"class":107}," }\n",[54,217,219],{"class":56,"line":218},8,[54,220,221],{"class":107},"  ],\n",[54,223,225,228,230],{"class":56,"line":224},9,[54,226,227],{"class":113},"  \"justMyCode\"",[54,229,117],{"class":107},[54,231,232],{"class":113},"false\n",[54,234,236],{"class":56,"line":235},10,[54,237,238],{"class":107},"}\n",[10,240,241,244],{},[14,242,243],{},"justMyCode: false"," matters when the interesting frame is inside a library — the default hides them, and a breakpoint set in a dependency never binds.",[10,246,247],{},"For a failure that needs the debugger only sometimes, attach from inside the code instead of at launch:",[45,249,253],{"className":250,"code":251,"language":252,"meta":50,"style":50},"language-python shiki shiki-themes github-light github-dark","import os\n\nif os.environ.get(\"WAIT_FOR_DEBUGGER\") == \"1\":\n    import debugpy\n    debugpy.listen((\"0.0.0.0\", 5678))\n    print(\"waiting for debugger to attach on :5678\", flush=True)\n    debugpy.wait_for_client()\n    debugpy.breakpoint()          # stop here as soon as the editor connects\n","python",[14,254,255,260,266,271,276,281,286,291],{"__ignoreMap":50},[54,256,257],{"class":56,"line":57},[54,258,259],{},"import os\n",[54,261,262],{"class":56,"line":63},[54,263,265],{"emptyLinePlaceholder":264},true,"\n",[54,267,268],{"class":56,"line":69},[54,269,270],{},"if os.environ.get(\"WAIT_FOR_DEBUGGER\") == \"1\":\n",[54,272,273],{"class":56,"line":75},[54,274,275],{},"    import debugpy\n",[54,277,278],{"class":56,"line":151},[54,279,280],{},"    debugpy.listen((\"0.0.0.0\", 5678))\n",[54,282,283],{"class":56,"line":182},[54,284,285],{},"    print(\"waiting for debugger to attach on :5678\", flush=True)\n",[54,287,288],{"class":56,"line":191},[54,289,290],{},"    debugpy.wait_for_client()\n",[54,292,293],{"class":56,"line":218},[54,294,295],{},"    debugpy.breakpoint()          # stop here as soon as the editor connects\n",[297,298,301,457],"figure",{"className":299},[300],"diagram",[302,303,310,311,310,315,310,319,310,349,310,357,310,365,310,374,310,380,310,386,310,389,310,392,310,395,310,398,310,402,310,405,310,411,310,417,310,422,310,427,310,430,310,434,310,437,310,441,310,448,310,452],"svg",{"viewBox":304,"role":305,"ariaLabelledBy":306,"xmlns":309},"0 0 760 430","img",[307,308],"debugpyattach-t","debugpyattach-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[312,313,314],"title",{"id":307},"The attach handshake",[316,317,318],"desc",{"id":308},"A sequence diagram with three lanes: the editor on the host, the published port, and the pytest process inside the container. The process starts a debug server and waits, the editor connects through the published port, breakpoints are registered with path mapping applied, and execution then begins.",[320,321,322,323,322,337,322,343,310],"defs",{},"\n    ",[324,325,332],"marker",{"id":326,"viewBox":327,"refX":328,"refY":329,"markerWidth":330,"markerHeight":330,"orient":331},"debugpyattach-a","0 0 10 10","9","5","7","auto-start-reverse",[333,334],"path",{"d":335,"fill":336},"M0 0 L10 5 L0 10 z","#3d405b",[324,338,340],{"id":339,"viewBox":327,"refX":328,"refY":329,"markerWidth":330,"markerHeight":330,"orient":331},"debugpyattach-c",[333,341],{"d":335,"fill":342},"#e07a5f",[324,344,346],{"id":345,"viewBox":327,"refX":328,"refY":329,"markerWidth":330,"markerHeight":330,"orient":331},"debugpyattach-s",[333,347],{"d":335,"fill":348},"#81b29a",[350,351],"rect",{"x":352,"y":352,"width":353,"height":354,"rx":355,"fill":356},"0","760","430","14","#fffdf8",[358,359,314],"text",{"x":360,"y":361,"textAnchor":362,"fontSize":363,"fontWeight":364,"fill":336},"380","30","middle","15","700",[350,366],{"x":367,"y":368,"width":369,"height":370,"rx":371,"fill":372,"stroke":336,"strokeWidth":373},"34","52","220","40","10","#f4f1de","1.8",[358,375,379],{"x":376,"y":377,"textAnchor":362,"fontSize":378,"fontWeight":364,"fill":336},"144","76","12","editor (host)",[56,381],{"x1":376,"y1":382,"x2":376,"y2":383,"stroke":336,"strokeWidth":384,"strokeDashArray":385},"98","414","1.2",[329,329],[350,387],{"x":388,"y":368,"width":369,"height":370,"rx":371,"fill":372,"stroke":336,"strokeWidth":373},"270",[358,390,391],{"x":360,"y":377,"textAnchor":362,"fontSize":378,"fontWeight":364,"fill":336},"published port",[56,393],{"x1":360,"y1":382,"x2":360,"y2":383,"stroke":336,"strokeWidth":384,"strokeDashArray":394},[329,329],[350,396],{"x":397,"y":368,"width":369,"height":370,"rx":371,"fill":372,"stroke":336,"strokeWidth":373},"506",[358,399,401],{"x":400,"y":377,"textAnchor":362,"fontSize":378,"fontWeight":364,"fill":336},"616","pytest (container)",[56,403],{"x1":400,"y1":382,"x2":400,"y2":383,"stroke":336,"strokeWidth":384,"strokeDashArray":404},[329,329],[56,406],{"x1":407,"y1":408,"x2":409,"y2":408,"stroke":336,"strokeWidth":373,"markerEnd":410},"608","126","388","url(#debugpyattach-a)",[358,412,416],{"x":413,"y":414,"textAnchor":362,"fontSize":415,"fill":336},"498","117","11","listen 0.0.0.0:5678",[56,418],{"x1":419,"y1":420,"x2":421,"y2":420,"stroke":336,"strokeWidth":373,"markerEnd":410},"152","180","372",[358,423,426],{"x":424,"y":425,"textAnchor":362,"fontSize":415,"fill":336},"262","171","connect 127.0.0.1:5678",[56,428],{"x1":409,"y1":429,"x2":407,"y2":429,"stroke":336,"strokeWidth":373,"markerEnd":410},"234",[358,431,433],{"x":413,"y":432,"textAnchor":362,"fontSize":415,"fill":336},"225","attach",[56,435],{"x1":419,"y1":436,"x2":407,"y2":436,"stroke":336,"strokeWidth":373,"markerEnd":410},"288",[358,438,440],{"x":360,"y":439,"textAnchor":362,"fontSize":415,"fill":336},"279","register breakpoints",[56,442],{"x1":407,"y1":443,"x2":419,"y2":443,"stroke":348,"strokeWidth":373,"strokeDashArray":444,"markerEnd":447},"342",[445,446],"6","4","url(#debugpyattach-s)",[358,449,451],{"x":360,"y":450,"textAnchor":362,"fontSize":415,"fill":336},"333","execution starts",[358,453,456],{"x":360,"y":454,"textAnchor":362,"fontSize":415,"fontStyle":455,"fill":336},"418","italic","Path mapping is applied when breakpoints are registered — a wrong root binds nothing.",[458,459,460],"figcaption",{},"Nothing runs until the client attaches, which is what makes import-time and collection-time failures debuggable.",[19,462,464],{"id":463},"why-this-works","Why this works",[10,466,467,469],{},[14,468,16],{}," implements the Debug Adapter Protocol over a TCP socket, and installs a tracing hook in the interpreter it runs inside. Because the transport is a socket, the debugger and the debuggee do not need to share a filesystem, a process namespace or a terminal — only a reachable port. Path mapping exists precisely because they do not share a filesystem: the editor sends breakpoints as local paths, and the adapter rewrites them to the container's paths before registering. When the mapping is wrong the protocol still succeeds; the breakpoints simply refer to files the interpreter never loads, which is why the symptom is silence rather than an error.",[297,471,473,564],{"className":472},[300],[302,474,310,479,310,482,310,485,310,487,310,489,310,495,310,499,310,503,310,507,310,511,310,515,310,518,310,521,310,524,310,527,310,530,310,533,310,536,310,539,310,542,310,545,310,548,310,551,310,554,310,557,310,561],{"viewBox":475,"role":305,"ariaLabelledBy":476,"xmlns":309},"0 0 760 270",[477,478],"debugpyflags-t","debugpyflags-d",[312,480,481],{"id":477},"The launcher options that matter",[316,483,484],{"id":478},"A table of four debugpy launcher options - listen, wait-for-client, the published port mapping, and justMyCode - describing what each controls and the failure that occurs without it.",[350,486],{"x":352,"y":352,"width":353,"height":388,"rx":355,"fill":356},[358,488,481],{"x":360,"y":361,"textAnchor":362,"fontSize":363,"fontWeight":364,"fill":336},[350,490],{"x":491,"y":368,"width":492,"height":370,"rx":371,"fill":493,"stroke":336,"strokeWidth":494},"24","712","#f2cc8f","1.5",[358,496,498],{"x":497,"y":377,"fontSize":378,"fontWeight":364,"fill":336},"38","Criterion",[358,500,502],{"x":501,"y":377,"textAnchor":362,"fontSize":378,"fontWeight":364,"fill":336},"390","Controls",[358,504,506],{"x":505,"y":377,"textAnchor":362,"fontSize":378,"fontWeight":364,"fill":336},"620","Without it",[350,508],{"x":491,"y":509,"width":492,"height":370,"fill":372,"stroke":336,"strokeWidth":510},"92","1",[358,512,83],{"x":497,"y":513,"fontSize":514,"fill":336},"116","11.5",[358,516,517],{"x":501,"y":513,"textAnchor":362,"fontSize":514,"fill":336},"who can connect",[358,519,520],{"x":505,"y":513,"textAnchor":362,"fontSize":514,"fill":336},"host cannot reach it",[350,522],{"x":491,"y":523,"width":492,"height":370,"fill":356,"stroke":336,"strokeWidth":510},"132",[358,525,91],{"x":497,"y":526,"fontSize":514,"fill":336},"156",[358,528,529],{"x":501,"y":526,"textAnchor":362,"fontSize":514,"fill":336},"start timing",[358,531,532],{"x":505,"y":526,"textAnchor":362,"fontSize":514,"fill":336},"import-time code already ran",[350,534],{"x":491,"y":535,"width":492,"height":370,"fill":372,"stroke":336,"strokeWidth":510},"172",[358,537,87],{"x":497,"y":538,"fontSize":514,"fill":336},"196",[358,540,541],{"x":501,"y":538,"textAnchor":362,"fontSize":514,"fill":336},"host exposure",[358,543,544],{"x":505,"y":538,"textAnchor":362,"fontSize":514,"fill":336},"port unreachable or public",[350,546],{"x":491,"y":547,"width":492,"height":370,"fill":356,"stroke":336,"strokeWidth":510},"212",[358,549,243],{"x":497,"y":550,"fontSize":514,"fill":336},"236",[358,552,553],{"x":501,"y":550,"textAnchor":362,"fontSize":514,"fill":336},"library frames",[358,555,556],{"x":505,"y":550,"textAnchor":362,"fontSize":514,"fill":336},"breakpoints in deps never bind",[56,558],{"x1":559,"y1":368,"x2":559,"y2":560,"stroke":336,"strokeWidth":510},"274","252",[56,562],{"x1":563,"y1":368,"x2":563,"y2":560,"stroke":336,"strokeWidth":510},"505",[458,565,566],{},"The first three are container plumbing and the fourth is an editor default that hides exactly the frames a hard bug lives in.",[19,568,570],{"id":569},"debugging-a-specific-test-in-a-compose-stack","Debugging a specific test in a compose stack",[10,572,573,574,577],{},"Real services rarely run alone. In a ",[14,575,576],{},"docker compose"," stack, the same technique applies to one service, with the debug command overriding its entry point:",[45,579,583],{"className":580,"code":581,"language":582,"meta":50,"style":50},"language-yaml shiki shiki-themes github-light github-dark","services:\n  api:\n    image: ghcr.io\u002Facme\u002Fapi@sha256:6b1c...\n    ports: [\"127.0.0.1:5678:5678\"]\n    command: >\n      python -m debugpy --listen 0.0.0.0:5678 --wait-for-client\n      -m pytest -q -x tests\u002Fintegration\u002Ftest_checkout.py\n    environment:\n      PYTHONHASHSEED: \"0\"\n      TZ: UTC\n    depends_on: [postgres, redis]\n","yaml",[14,584,585,594,601,611,625,636,641,646,653,663,673],{"__ignoreMap":50},[54,586,587,591],{"class":56,"line":57},[54,588,590],{"class":589},"s9eBZ","services",[54,592,593],{"class":107},":\n",[54,595,596,599],{"class":56,"line":63},[54,597,598],{"class":589},"  api",[54,600,593],{"class":107},[54,602,603,606,608],{"class":56,"line":69},[54,604,605],{"class":589},"    image",[54,607,117],{"class":107},[54,609,610],{"class":120},"ghcr.io\u002Facme\u002Fapi@sha256:6b1c...\n",[54,612,613,616,619,622],{"class":56,"line":75},[54,614,615],{"class":589},"    ports",[54,617,618],{"class":107},": [",[54,620,621],{"class":120},"\"127.0.0.1:5678:5678\"",[54,623,624],{"class":107},"]\n",[54,626,627,630,632],{"class":56,"line":151},[54,628,629],{"class":589},"    command",[54,631,117],{"class":107},[54,633,635],{"class":634},"szBVR",">\n",[54,637,638],{"class":56,"line":182},[54,639,640],{"class":120},"      python -m debugpy --listen 0.0.0.0:5678 --wait-for-client\n",[54,642,643],{"class":56,"line":191},[54,644,645],{"class":120},"      -m pytest -q -x tests\u002Fintegration\u002Ftest_checkout.py\n",[54,647,648,651],{"class":56,"line":218},[54,649,650],{"class":589},"    environment",[54,652,593],{"class":107},[54,654,655,658,660],{"class":56,"line":224},[54,656,657],{"class":589},"      PYTHONHASHSEED",[54,659,117],{"class":107},[54,661,662],{"class":120},"\"0\"\n",[54,664,665,668,670],{"class":56,"line":235},[54,666,667],{"class":589},"      TZ",[54,669,117],{"class":107},[54,671,672],{"class":120},"UTC\n",[54,674,676,679,681,684,686,689],{"class":56,"line":675},11,[54,677,678],{"class":589},"    depends_on",[54,680,618],{"class":107},[54,682,683],{"class":120},"postgres",[54,685,168],{"class":107},[54,687,688],{"class":120},"redis",[54,690,624],{"class":107},[10,692,693,694,696],{},"The dependencies start normally and the test session waits for the editor, so the debugger is attached before any fixture builds. That ordering is the reason to prefer ",[14,695,91],{}," over attaching later: a fixture that fails during setup is otherwise gone before the editor connects.",[10,698,699,700,703],{},"Two practical notes for stacks. ",[14,701,702],{},"depends_on"," does not wait for readiness, so a database that is still starting will fail the fixture before you can inspect anything — add a health check. And keep the debug configuration in a separate compose override file rather than in the main one, so nobody accidentally ships a service that waits forever for a debugger.",[19,705,707],{"id":706},"safety-in-shared-environments","Safety in shared environments",[10,709,710],{},"A debug port is remote code execution by design. Four rules keep that from becoming an incident.",[10,712,713,714,717,718,721,722,725,726,728],{},"Bind the published port to ",[14,715,716],{},"127.0.0.1"," and never to ",[14,719,720],{},"0.0.0.0"," on the host. Use an SSH tunnel when the container runs on a remote machine — ",[14,723,724],{},"ssh -L 5678:localhost:5678 host"," — rather than opening the port on the network. Gate the in-code attach behind an environment variable that is unset everywhere except your debugging run. And never install this configuration in a production image: the presence of ",[14,727,16],{}," and a listening socket in a production container is a finding in any security review, and correctly so.",[10,730,731],{},"For CI specifically, a debug-enabled job should be manually triggered, time-limited and self-terminating. A job that waits indefinitely for a debugger occupies a runner until it times out, and a forgotten one occupies it for the full timeout every time it is accidentally started.",[19,733,735],{"id":734},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,737,738,748,756,762,776,785],{},[27,739,740,743,744,747],{},[30,741,742],{},"Breakpoints bind but never hit"," — the path mapping is right for one directory and wrong for another; check ",[14,745,746],{},"remoteRoot"," against the container's actual working directory.",[27,749,750,755],{},[30,751,752,754],{},[14,753,91],{}," with no editor hangs forever."," Always run such containers with a timeout, and in CI with an explicit job timeout.",[27,757,758,761],{},[30,759,760],{},"Reloaders defeat attachment."," A dev server with autoreload spawns a child, and the debugger is attached to the parent; disable reload for debugging runs.",[27,763,764,771,772,775],{},[30,765,766,767,770],{},"Under ",[14,768,769],{},"pytest-xdist"," the workers are separate processes"," without the debug server; run with ",[14,773,774],{},"-n 0"," or attach to a specific worker with a conditional in-code listen.",[27,777,778,781,782,784],{},[30,779,780],{},"Alpine images need musl-compatible wheels."," ",[14,783,16],{}," installs fine, but a mismatch produces an import error rather than a connection failure.",[27,786,787,790],{},[30,788,789],{},"The tracing hook slows execution."," A suite that is timing-sensitive may behave differently under the debugger, which is itself a hint about the bug.",[19,792,794],{"id":793},"alternatives-that-need-no-debugger","Alternatives that need no debugger",[10,796,797],{},"A remote debugger is the heaviest tool in the CI kit, and three lighter ones solve most of what people reach for it to do.",[10,799,800,803,804,809],{},[30,801,802],{},"Conditional state dumps."," A hook that writes locals and environment on failure — as set up in ",[805,806,808],"a",{"href":807},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002F","debugging tests in CI and containers"," — answers \"what was the state\" without any interactive session, and works in parallel runs where a debugger cannot.",[10,811,812,781,815,818,819,822],{},[30,813,814],{},"Increased logging on the failing path only.",[14,816,817],{},"--log-cli-level=DEBUG"," combined with ",[14,820,821],{},"-k"," on the single failing test produces a focused trace, and unlike a debugger it can run unattended in the pipeline:",[45,824,826],{"className":47,"code":825,"language":49,"meta":50,"style":50},"$ pytest -q -k test_checkout --log-cli-level=DEBUG -o log_cli=true\n",[14,827,828],{"__ignoreMap":50},[54,829,830],{"class":56,"line":57},[54,831,825],{},[10,833,834,837,838,841],{},[30,835,836],{},"A one-shot stack dump."," For a hang rather than a failure, ",[14,839,840],{},"py-spy dump --pid 1"," inside the container shows every thread's stack in a second, with no setup and no restart. That is usually enough to distinguish a deadlock from a slow query, which is the question a hanging job actually poses.",[10,843,844,845,847,848,851],{},"Reach for ",[14,846,16],{}," when the answer depends on stepping — a condition whose inputs are computed across several frames, a loop whose behaviour changes on the eleventh iteration, a library call whose return value you need to inspect before it is transformed. Those are real cases, and for them nothing else works. For everything else, an artefact you can read after the job ends beats a session you have to be present for.\nOne workflow detail that saves repeated setup: keep the debug launch configuration in the repository alongside a compose override file, so attaching is two commands rather than a remembered incantation. ",[14,849,850],{},"docker compose -f compose.yaml -f compose.debug.yaml up api"," and a single editor action is the difference between reaching for the debugger when it would help and avoiding it because the setup takes ten minutes.",[19,853,855],{"id":854},"frequently-asked-questions","Frequently Asked Questions",[10,857,858,861],{},[30,859,860],{},"Why does my debugger connect but never hit a breakpoint?","\nAlmost always a path mapping problem: the editor's file paths do not match the container's, so the breakpoint is registered against a path the interpreter never executes. Configure the local and remote roots explicitly.",[10,863,864,867],{},[30,865,866],{},"Should debugpy listen on 0.0.0.0?","\nInside a container, yes — it must accept a connection from the host. Publish the port only to localhost and never expose it from a shared or production environment.",[10,869,870,873,874,877,878,881,882,884,885,888,889,892],{},[30,871,872],{},"How do I debug a test that fails immediately at import?","\nUse ",[14,875,876],{},"debugpy.wait_for_client()"," before the code runs, or start the process with the debugpy launcher and the wait flag so nothing executes until the editor attaches.\n",[30,879,880],{},"Can I attach to a test running under pytest-xdist?","\nNot to the run as a whole. Each worker is a separate process, and only one can hold the debug port. Either run the failing test with ",[14,883,774],{}," to disable distribution, or start the listener conditionally inside the worker you care about by checking ",[14,886,887],{},"PYTEST_XDIST_WORKER"," before calling ",[14,890,891],{},"debugpy.listen",". The second approach is useful when the failure only occurs under parallel execution, which is precisely the case where the first is unavailable.",[297,894,896,1020],{"className":895},[300],[302,897,310,902,310,905,310,908,310,930,310,933,310,935,310,939,310,943,310,947,310,952,310,957,310,963,310,968,310,971,310,975,310,979,310,983,310,986,310,989,310,992,310,995,310,999,310,1004,310,1007,310,1010,310,1013,310,1016],{"viewBox":898,"role":305,"ariaLabelledBy":899,"xmlns":309},"0 0 760 332",[900,901],"debugroute-t","debugroute-d",[312,903,904],{"id":900},"Which remote-debugging route fits",[316,906,907],{"id":901},"A decision diagram: artefacts suffice for most failures, a log-level increase covers the rest, and a debugpy session is reserved for failures that require stepping through state.",[320,909,322,910,322,915,322,920,322,925,310],{},[324,911,913],{"id":912,"viewBox":327,"refX":328,"refY":329,"markerWidth":330,"markerHeight":330,"orient":331},"debugroute-a",[333,914],{"d":335,"fill":336},[324,916,918],{"id":917,"viewBox":327,"refX":328,"refY":329,"markerWidth":330,"markerHeight":330,"orient":331},"debugroute-c",[333,919],{"d":335,"fill":342},[324,921,923],{"id":922,"viewBox":327,"refX":328,"refY":329,"markerWidth":330,"markerHeight":330,"orient":331},"debugroute-s",[333,924],{"d":335,"fill":348},[324,926,928],{"id":927,"viewBox":327,"refX":328,"refY":329,"markerWidth":330,"markerHeight":330,"orient":331},"debugroute-g",[333,929],{"d":335,"fill":493},[350,931],{"x":352,"y":352,"width":353,"height":932,"rx":355,"fill":356},"332",[358,934,904],{"x":360,"y":361,"textAnchor":362,"fontSize":363,"fontWeight":364,"fill":336},[936,937],"polygon",{"points":938,"fill":372,"stroke":336,"strokeWidth":373},"380,50 590,108 380,166 170,108",[358,940,942],{"x":360,"y":941,"textAnchor":362,"fontSize":378,"fontWeight":364,"fill":336},"104","What do you need",[358,944,946],{"x":360,"y":945,"textAnchor":362,"fontSize":378,"fontWeight":364,"fill":336},"121","from the failing run?",[333,948],{"d":949,"fill":950,"stroke":348,"strokeWidth":373,"markerEnd":951},"M380 166 L380 192 L133 192 L133 206","none","url(#debugroute-s)",[358,953,956],{"x":954,"y":538,"textAnchor":362,"fontSize":415,"fontWeight":364,"fill":955},"133","#2a5f49","state at failure",[350,958],{"x":491,"y":959,"width":960,"height":961,"rx":378,"fill":356,"stroke":348,"strokeWidth":962},"214","217","84","2",[358,964,967],{"x":954,"y":965,"textAnchor":362,"fontSize":966,"fontWeight":364,"fill":336},"245","12.5","artefact dump",[358,969,970],{"x":954,"y":424,"textAnchor":362,"fontSize":415,"fill":336},"works in parallel",[358,972,974],{"x":954,"y":973,"textAnchor":362,"fontSize":415,"fill":336},"277","no session needed",[333,976],{"d":977,"fill":950,"stroke":493,"strokeWidth":373,"markerEnd":978},"M380 166 L380 192 L380 192 L380 206","url(#debugroute-g)",[358,980,982],{"x":360,"y":538,"textAnchor":362,"fontSize":415,"fontWeight":364,"fill":981},"#8a5a00","a detailed trace",[350,984],{"x":985,"y":959,"width":960,"height":961,"rx":378,"fill":356,"stroke":493,"strokeWidth":962},"271",[358,987,988],{"x":360,"y":965,"textAnchor":362,"fontSize":966,"fontWeight":364,"fill":336},"log-cli-level",[358,990,991],{"x":360,"y":424,"textAnchor":362,"fontSize":415,"fill":336},"unattended",[358,993,994],{"x":360,"y":973,"textAnchor":362,"fontSize":415,"fill":336},"focused with -k",[333,996],{"d":997,"fill":950,"stroke":342,"strokeWidth":373,"markerEnd":998},"M380 166 L380 192 L627 192 L627 206","url(#debugroute-c)",[358,1000,1003],{"x":1001,"y":538,"textAnchor":362,"fontSize":415,"fontWeight":364,"fill":1002},"627","#8f3d22","to step through",[350,1005],{"x":1006,"y":959,"width":960,"height":961,"rx":378,"fill":356,"stroke":342,"strokeWidth":962},"519",[358,1008,1009],{"x":1001,"y":965,"textAnchor":362,"fontSize":966,"fontWeight":364,"fill":336},"debugpy attach",[358,1011,1012],{"x":1001,"y":424,"textAnchor":362,"fontSize":415,"fill":336},"one process only",[358,1014,1015],{"x":1001,"y":973,"textAnchor":362,"fontSize":415,"fill":336},"manual trigger",[358,1017,1019],{"x":360,"y":1018,"textAnchor":362,"fontSize":415,"fontStyle":455,"fill":336},"320","py-spy dump answers hangs without any of these.",[458,1021,1022],{},"Escalate rather than starting at the heaviest tool: two thirds of CI failures are answered by the first column.",[19,1024,1026],{"id":1025},"related","Related",[24,1028,1029,1035,1042,1049],{},[27,1030,1031,1034],{},[805,1032,1033],{"href":807},"Debugging tests in CI and containers"," — artefact capture, which solves most failures without a debugger.",[27,1036,1037,1041],{},[805,1038,1040],{"href":1039},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Freproducing-ci-only-test-failures-locally\u002F","Reproducing CI-only test failures locally"," — the cheaper first move.",[27,1043,1044,1048],{},[805,1045,1047],{"href":1046},"\u002Fsystematic-debugging-performance-profiling\u002Finteractive-debugging-with-pdb-and-ipdb\u002Fdropping-into-pdb-on-test-failure\u002F","Dropping into pdb on test failure with --pdb"," — the local equivalent when a terminal is available.",[27,1050,1051,1055],{},[805,1052,1054],{"href":1053},"\u002Fsystematic-debugging-performance-profiling\u002Fcpu-profiling-with-cprofile-and-py-spy\u002Fprofiling-a-running-process-with-py-spy\u002F","Profiling a running process with py-spy"," — inspecting a container without stopping it.",[10,1057,1058,1059],{},"← Back to ",[805,1060,1061],{"href":807},"Debugging Tests in CI and Containers",[1063,1064,1065],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":50,"searchDepth":63,"depth":63,"links":1067},[1068,1069,1070,1071,1072,1073,1074,1075,1076],{"id":21,"depth":63,"text":22},{"id":39,"depth":63,"text":40},{"id":463,"depth":63,"text":464},{"id":569,"depth":63,"text":570},{"id":706,"depth":63,"text":707},{"id":734,"depth":63,"text":735},{"id":793,"depth":63,"text":794},{"id":854,"depth":63,"text":855},{"id":1025,"depth":63,"text":1026},"Debug a containerised Python test run with debugpy: listen and wait-for-client, port publishing, path mapping, pytest integration, and safe use in shared environments.","md",{"slug":1080,"type":1081,"breadcrumb":1082,"datePublished":1083,"dateModified":1083,"faq":1084,"howto":1091},"attaching-debugpy-to-a-container","article","debugpy Attach","2026-08-01",[1085,1087,1089],{"q":860,"a":1086},"Almost always a path mapping problem: the editor's file paths do not match the container's, so the breakpoint is registered against a path the interpreter never executes. Configure the local and remote roots explicitly.",{"q":866,"a":1088},"Inside a container, yes — it must accept a connection from the host. Publish the port only to localhost and never expose it from a shared or production environment.",{"q":872,"a":1090},"Use debugpy.wait_for_client() before the code runs, or start the process with the debugpy launcher and the wait flag so nothing executes until the editor attaches.",{"name":1092,"description":1093,"steps":1094},"How to attach debugpy to a containerised test run","Open a debug port inside a container and attach an editor to a running test session.",[1095,1098,1101,1104],{"name":1096,"text":1097},"Install debugpy in the image","Add it to the test requirements so the container can start a debug server.",{"name":1099,"text":1100},"Start pytest under the debugpy launcher","Listen on all interfaces inside the container and wait for a client.",{"name":1102,"text":1103},"Publish the port to localhost","Map the container port so the editor can connect from the host.",{"name":1105,"text":1106},"Configure path mapping","Tell the editor how local paths correspond to container paths so breakpoints bind.","\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fattaching-debugpy-to-a-container",{"title":5,"description":1077},"systematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fattaching-debugpy-to-a-container\u002Findex","Y65BHmLF5SgyCeYqhmvYKzizopq7WboGoLcJCulA_vQ",1785613403107]