GitHub Copilot for Python
Python is the language where Copilot feels most effortless and where that feeling is most dangerous. The syntax is close to the natural language you prompt in, the standard library is enormous and well represented in public code, and almost everything the model produces will import cleanly and run.
That last part is the problem. Python will not tell you the suggestion is wrong. It has no compiler to consult, no ownership model, and — by default — no type checking at all. A generated function that returns a string where the caller expects a float is a perfectly valid Python program right up until the moment it is not.
This lesson is about closing that gap deliberately: giving Copilot the constraints Python does not require, and turning on the tools that convert those constraints into a gate.
Python quick referenceVerified August 21, 2026
- Type system
- Dynamically typed at runtime; type hints are optional annotations that tools — not the interpreter — enforce.
- Package management
- pip (PyPI); uv and Poetry are common front-ends
- Manifest Copilot should see
- pyproject.toml or requirements.txt
- Testing
- pytest, unittest
- Formatting
- Ruff formatter or Black
- Linting
- Ruff
- Static analysis
- mypy or Pyright
- Common frameworks
- FastAPI, Django, Flask, pandas
- Typical Copilot work
- HTTP endpoints, data transforms, automation scripts, pytest suites, docstrings
- First thing to review
- Silent type mismatches — nothing fails until the wrong value reaches production
Fastest honest checkmypy . && pytest -q
Key takeaways
- Type hints are the highest-leverage thing you can add. They improve the
suggestion going in and let
mypyor Pyright reject a wrong one coming out — but only if you actually run the checker. - Copilot cannot see your virtual environment. It will suggest APIs from the version of a library that was most common in public code, not the one you installed.
- Generated Python omits error handling by default, because the shortest correct-looking implementation omits it.
subprocesswithshell=True,pickleon untrusted data, andyaml.loadwithout a safe loader are the three security patterns worth grepping for in every generated Python diff.- The fastest honest check is
mypy . && pytest -q. It takes seconds and eliminates an entire category of problem before you read the code.
Why Python needs more prompt discipline than a typed language
In Java, a method signature tells the model
most of what it needs. List<Order> settle(Invoice invoice) constrains the
answer sharply.
The Python equivalent, def settle(invoice):, constrains nothing. Is invoice a
dataclass, a dict, an ORM instance, an ID? Does the function return a list, a
generator, None? Copilot will pick — fluently and confidently — and the
interpreter will not object.
So the first rule for Python is: write the signature before you write the prompt. Not the body. Just the signature, with types.
def settle(invoice: Invoice, *, dry_run: bool = False) -> list[Payment]:
"""Apply available credit to an invoice and return the payments created."""Given that, the suggestion has a shape to fill. Given def settle(invoice):, it
has a story to invent.
Environment and tooling: what Copilot can and cannot see
Copilot’s context comes from your open files and your cursor’s surroundings. It
does not include the contents of your virtual environment, the output of
pip list, or the version constraints your CI resolves to.
That has three practical consequences.
Keep your manifest open. pyproject.toml or requirements.txt is the single
most valuable file to have in a tab. It is how a suggestion gets anchored to the
libraries you actually have.
State versions in the prompt when the API has moved. Pydantic v1 and v2 differ
substantially. SQLAlchemy 1.4 and 2.0 differ substantially. datetime.utcnow()
is deprecated in favour of timezone-aware alternatives. Public code contains
years of all the older forms.
Expect environment-independent suggestions. If you ask for something that needs a package you do not have, Copilot will import it without hesitating. In a compiled language that import fails at build time. In Python it fails at runtime, possibly in a code path your tests do not reach.
Practical project: a health-check API
Practical example
A FastAPI health-check service, generated then verified
Build a small service with a typed response contract, then run the exact tool chain that would have caught two realistic bad suggestions.
- Status
- Tested implementation
- Runtime
- Python 3.12.3, FastAPI 0.141.1, pytest 9.1.1, httpx 0.28.1, mypy 2.3.1, Ruff 0.16.4
- Command
pytest -q && mypy app tests && ruff check app tests- Result
- 4 passed; mypy: Success: no issues found in 4 source files; ruff: All checks passed!
- Run on
- August 21, 2026
Files
copilot-python-demo/ ├── app/ │ ├── init.py │ └── main.py ├── tests/ │ ├── init.py │ └── test_main.py └── requirements.txt
The prompt
Create a FastAPI health-check endpoint at GET /healthz.
Return a Pydantic model with: status (Literal “ok” or “degraded”), uptime_seconds (float), version (str), and checks (dict of str to bool).
Run each dependency check in a separate function so tests can patch it.
Return HTTP 503 when any check is false, 200 otherwise.
Type-annotate every function. No print statements.
Every clause there removes a decision the model would otherwise make silently:
the exact path, the field names, the literal values status may take, where the
status code comes from, and the seam the tests need.
Implementation
"""Health-check service for the Copilot Stack Python lesson."""
from __future__ import annotations
import os
import time
from typing import Literal
from fastapi import FastAPI, Response, status
from pydantic import BaseModel
STARTED_AT = time.monotonic()
app = FastAPI(title="copilot-python-demo")
class HealthResponse(BaseModel):
"""The response contract, declared once and enforced by FastAPI."""
status: Literal["ok", "degraded"]
uptime_seconds: float
version: str
checks: dict[str, bool]
def run_checks() -> dict[str, bool]:
"""Every dependency this service needs in order to serve traffic."""
return {
"config_loaded": bool(os.environ.get("DEMO_VERSION", "0.1.0")),
"clock_monotonic": time.monotonic() >= STARTED_AT,
}
@app.get("/healthz", response_model=HealthResponse)
def healthz(response: Response) -> HealthResponse:
checks = run_checks()
healthy = all(checks.values())
if not healthy:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return HealthResponse(
status="ok" if healthy else "degraded",
uptime_seconds=round(time.monotonic() - STARTED_AT, 3),
version=os.environ.get("DEMO_VERSION", "0.1.0"),
checks=checks,
)time.monotonic() rather than time.time() is a decision worth naming: uptime
computed from wall-clock time goes negative when NTP steps the clock backwards.
This is exactly the class of thing a model gets right or wrong depending on the
prior, and exactly the class of thing no test you were going to write would
catch.
Tests
from fastapi.testclient import TestClient
from app.main import app, run_checks
client = TestClient(app)
def test_healthz_returns_ok_when_all_checks_pass() -> None:
response = client.get("/healthz")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert body["checks"] == {"config_loaded": True, "clock_monotonic": True}
def test_healthz_returns_503_when_a_check_fails(monkeypatch) -> None:
monkeypatch.setattr("app.main.run_checks", lambda: {"config_loaded": False})
response = client.get("/healthz")
assert response.status_code == 503
assert response.json()["status"] == "degraded"Run
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest -q
mypy app tests
ruff check app testsObserved output:
4 passed, 1 warning in 1.32s
Success: no issues found in 4 source files
All checks passed!Review: two suggestions the tooling actually caught
Both of these are realistic — they are the shapes generated Python takes when the prompt is looser than the one above. Both were introduced into this exact project and both were caught in under a second.
A return type quietly changed to a formatted string.
uptime_seconds=f"{time.monotonic() - STARTED_AT:.3f}",That is a perfectly reasonable-looking line. It produces "12.480" instead of
12.48, which will serialise, will look right in a browser, and will break any
consumer that does arithmetic on it. mypy reported:
app/main.py:42: error: Argument "uptime_seconds" to "HealthResponse" has
incompatible type "str"; expected "float" [arg-type]Without the annotation on HealthResponse.uptime_seconds, there is nothing to
report. The type hint is what created the error.
A mutable default argument.
def run_checks(seen: list[str] = []) -> dict[str, bool]:The default list is created once, at function definition, and shared by every
call — so it accumulates across requests. Ruff’s B006 catches it:
B006 Do not use mutable data structures for argument defaults
--> app/main.py:26:34
help: Replace with `None`; initialize within functionThis is the single most characteristic Python bug in generated code, because the pattern appears constantly in public examples where it happens to be harmless.
Core Python workflows
The language constructs Copilot handles well, and the specific thing to check in each.
Functions and signatures. Strongest when you have written the signature. Ask
for keyword-only arguments explicitly (* in the parameter list) when the call
site would otherwise be a row of unlabelled positional values — the model will
not add it unprompted, and a five-positional-argument function is a bug waiting
for someone to swap two of them.
Classes and dataclasses. Copilot writes @dataclass well, including
field(default_factory=list) — which is the correct answer to the mutable-default
problem and one it usually gets right in a dataclass while getting it wrong in
a plain function signature. Check frozen=True if you meant the instance to be
immutable, and check eq/order if you are going to sort them.
Type hints. Modern built-in generics (list[str], dict[str, int],
str | None) rather than the older typing.List forms. If a suggestion uses
Optional[X] and Union[A, B] throughout, that is a signal it is drawing on
older public code, and worth a second look at the rest of the suggestion for the
same reason. from __future__ import annotations at the top of a module makes
the newer syntax available on older interpreters.
Comprehensions. Copilot’s comprehensions are usually correct and sometimes too clever — a triple-nested comprehension with a conditional is harder to review than the loop it replaces. When you get one, ask yourself whether you could spot an off-by-one in it. If not, ask for the loop.
Exceptions. The default output catches too broadly or not at all. Look for
bare except: (catches KeyboardInterrupt and SystemExit too) and for
except Exception: pass, which is how errors disappear. Ask for the specific
exception types and for the failure to be re-raised or logged with context.
Context managers. Anything holding a file, a socket, a lock or a database
connection should be in a with block. Generated code that opens a file and
closes it manually will leak on the exception path. When you need your own, ask
for contextlib.contextmanager rather than a class with __enter__/__exit__
unless you need the class.
async/await. The section below on failure modes covers the correctness
traps. The workflow point: state whether you want sync or async in the prompt.
Ask for “an async FastAPI handler using httpx” and you get one; ask for “a
FastAPI handler that calls the API” and you may get a synchronous requests call
inside an async def, which is worse than either consistent choice.
Generators. Copilot reaches for yield appropriately, but check whether the
caller can consume a generator. Returning one where a list was expected produces
code that works the first time and returns nothing the second.
Virtual environments, packaging and the manifest
Copilot has no visibility into your environment, so this section is about giving it a proxy for one.
venv remains the standard-library answer and needs no prompt help. The part
worth getting right is the manifest, because that is the file Copilot can read.
A pyproject.toml with a real dependencies list and a [project.optional- dependencies] block for dev tooling gives the model version information that a
bare requirements.txt of unpinned names does not. If your project still uses
requirements.txt, pin versions in it — the pins are useful to you and useful as
context.
When you ask Copilot to add a dependency, ask it to update the manifest in the same edit. A suggestion that adds an import without touching the manifest produces code that works on your machine and fails in CI, which is the most expensive place to find out.
Static analysis and formatting
The Python tooling landscape has consolidated, and for the purpose of reviewing generated code the choice matters less than the fact that something runs.
Ruff covers linting and formatting in one tool and is fast enough to run on
save. For this lesson’s purposes the important part is its bugbear (B) rule
set, which targets exactly the patterns that recur in generated Python: mutable
defaults, loop variables captured in closures, assert on a tuple, mutable class
attributes.
Black remains widely used and entirely fine; Ruff’s formatter is Black-compatible by design. Either one removes formatting from the review entirely, which matters more than which you pick — a diff where half the noise is whitespace is a diff where the real change hides.
mypy and Pyright are the two mainstream type checkers. Pyright is what most editors run for inline feedback; mypy is what most projects run in CI. They disagree at the edges. Running one is enormously better than running neither.
Documentation
Docstrings are one of the safer Copilot tasks, with one caveat that is easy to miss: a generated docstring describes what the code appears to do. If the implementation is subtly wrong, the docstring will confidently document the wrong behaviour, and now the bug has documentation defending it.
The useful pattern is the reverse. Write the docstring first — including the
Raises: section and the boundary behaviour — and let it constrain the
implementation. That turns the docstring from a description into a specification,
and gives the model the same information a type hint gives it.
For a module you are trying to understand rather than write, asking chat to explain a function and then comparing its answer to the docstring is a fast way to find places where the two have drifted apart.
Testing: getting pytest to disagree with the implementation
Copilot is genuinely strong at pytest. The failure mode is not weak test code; it is tests that agree with the bug.
If you highlight a function and ask for tests, the model reads the implementation and asserts what it does. If the implementation rounds the wrong way, so does the test, and now you have a green suite defending a defect.
The fix is to describe the requirement instead:
Write pytest tests for a function that must return nearest-rank p50, p95 and p99 from a list of floats.
Cover: an unsorted input, a single element, two elements, an already-sorted input, and an empty list which must raise ValueError.
Do not read my implementation. Write the tests from this description only, so they can disagree with it.
Two more pytest-specific habits worth having:
- Ask for
pytest.mark.parametrizeexplicitly. Left alone, the model writes five near-identical test functions. Parametrised tables are easier to extend and make the missing case visible. - Ask for the fixture, not the setup code. “Use a fixture for the client” produces reusable structure; not asking produces the same three lines copied into every test.
Debugging and refactoring
Debugging is where Copilot earns the most in Python, precisely because tracebacks are rich, precise context. Paste the whole traceback — not the last line — into chat and ask what it means. The frames above the exception are usually where the answer is.
For refactoring, the honest rule is: the safety of a Python refactor is exactly your test coverage. Renaming a method across a typed codebase is a compiler-verified operation; in Python it is a text substitution with hopes attached. Before accepting a multi-file refactor, check that the tests were passing before it, and that they exercise the code being moved.
Python-specific failure modes
These are the patterns worth looking for specifically in Python. Each is common in public code, which is why it recurs in suggestions.
Missing exception handling. File I/O without FileNotFoundError, network
calls without a timeout, json.loads without JSONDecodeError. Watch for
requests.get(url) with no timeout= — it will hang forever by default.
Mutable default arguments. Covered above. Ruff’s B006 finds them.
Incorrect async use. A blocking call inside an async def stalls the whole
event loop. time.sleep instead of asyncio.sleep, a synchronous database
driver, requests instead of httpx in an async handler. None of these fail —
they just serialise your concurrency.
Fabricated packages and outdated APIs. Check imports against your manifest. Version drift is the more common case: the package exists, the method does not.
Incorrect type assumptions. dict.get() returning None and being used
immediately; assuming a list where a generator is returned; assuming ordering
from a set.
Hidden side effects. Module-level code that runs on import — opening a file, reading an environment variable, establishing a connection. Fine in a script, a problem in a library, and invisible until something imports it in a test.
Unsafe deserialisation and subprocess use. See below.
Numbers and time: where generated Python is weakest
Two domains deserve their own section because the failure is silent, plausible and common.
Money in floating point. Ask for a price calculation and you will usually get
float. 0.1 + 0.2 is not 0.3 in binary floating point, and a rounding
discrepancy in a totals column is the kind of bug that gets found by an
accountant rather than by a test. If the value is money, say so in the prompt and
ask for decimal.Decimal with an explicit quantisation, or for integer minor
units. The model will comply immediately — it simply does not assume it.
Naive datetimes. Generated Python reaches for datetime.now() and
datetime.utcnow() because public code is full of both. utcnow() returns a
naive datetime that claims nothing about its timezone, which then compares
incorrectly against an aware one and raises, or worse, silently participates in
arithmetic that is off by your server’s offset. It is deprecated in current
Python in favour of datetime.now(timezone.utc). Ask for timezone-aware
datetimes explicitly, and check any suggestion that formats or parses a timestamp
for whether the timezone survived the round trip.
Related and equally silent: round() uses banker’s rounding, so round(2.5) is
2 and round(3.5) is 4. That is correct Python and frequently not what the
requirement meant. If a suggestion rounds a user-visible number, confirm which
rounding mode the requirement actually wanted.
Write a function that totals a list of line items and applies a percentage discount.
Use decimal.Decimal throughout; never float. Quantise the result to two decimal places with ROUND_HALF_UP.
Timestamps are timezone-aware UTC datetimes. Reject naive datetimes with ValueError.
Include pytest tests covering the rounding boundary at exactly half a cent.
That prompt is longer than the function it produces. That is the correct ratio for anything involving money, and it is the general shape of the discipline this lesson is arguing for: in a language that checks nothing by default, the constraints have to come from you.
Review workflow
- Run mypyCatches the return-type class of bug in under a second — but only for annotated code.
- Run RuffMutable defaults, unused imports, shadowed builtins, and a large set of bug-prone patterns.
- Read the importsHuman judgementIn the manifest? Right version? Actually exists?
- Read the error pathsHuman judgementTimeouts, except blocks, and what happens on an empty collection.
- Grep for subprocess, pickle, yaml.load, evalHuman judgementFour greps, and they cover most of the serious security surface.
- Run pytestAnd confirm the tests were written from the requirement, not from the implementation.
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Write the typed signature and docstring first; let Copilot fill the body.
- Keep
pyproject.tomlopen, and name library versions in the prompt when the API has moved between majors. - Run
mypyin CI. Type hints nobody checks are comments. - Enable Ruff’s
B(bugbear) rules — they cover the Python-specific traps that generated code falls into most often. - Generate tests from descriptions of required behaviour, never from the implementation.
- Treat any suggested
shell=True,pickle,evalor bareexcept:as a review stop, not a style preference.
Common mistakes
- Accepting a suggestion because it runs. Running is the weakest possible signal in Python.
- Adding type hints and never running a checker.
- Asking for tests with the implementation selected, then trusting the green.
- Letting a hallucinated import through because the code path is not covered.
- Assuming an
asyncsuggestion is actually concurrent.
Where to go next
GitHub Copilot with PyCharm in Cluster 2 covers the editor side — interpreter awareness, inline test running, and how PyCharm’s own inspections overlap with what is described here. GitHub Copilot for SQL is the natural follow-on if your Python touches a database, and GitHub Copilot for Bash if it shells out. For the review discipline this lesson assumes, see GitHub Copilot Best Practices.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.