GitHub Copilot for Python

GitHub Copilot Programming LanguagesAcademy lesson 27Cluster 3 · Lesson 2 of 13Intermediate16 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for PythonGitHub Copilot Programming Languages2Intermediate/github-copilot/languages/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.

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

copilot-python-demo/ ├── app/ │ ├── init.py │ └── main.py ├── tests/ │ ├── init.py │ └── test_main.py └── requirements.txt

The prompt

Copilot promptGenerate the serviceCopilot Chat, with pyproject.toml open

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 tests

Observed 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 function

This 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:

Copilot promptTests from behaviour, not from codeCopilot Chat

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.parametrize explicitly. 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.

Copilot promptRemove the numeric and temporal ambiguity up frontCopilot Chat

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

Accepting a Python suggestion
  1. Run mypyCatches the return-type class of bug in under a second — but only for annotated code.
  2. Run RuffMutable defaults, unused imports, shadowed builtins, and a large set of bug-prone patterns.
  3. Read the importsHuman judgementIn the manifest? Right version? Actually exists?
  4. Read the error pathsHuman judgementTimeouts, except blocks, and what happens on an empty collection.
  5. Grep for subprocess, pickle, yaml.load, evalHuman judgementFour greps, and they cover most of the serious security surface.
  6. 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.toml open, and name library versions in the prompt when the API has moved between majors.
  • Run mypy in 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, eval or bare except: 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 async suggestion 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.

Primary sources