Skip to content

RFC: Adding inline type annotations to Requests #7271

Description

@nateprewitt

Important

Before reporting new issues, be sure to check this comment to see if your problem is already being addressed.

Motivation

Requests has notably been without inline type annotations since type hints entered the Python ecosystem. We've had aspirations of bringing Requests up to par with community standards for a few years but have been unable to properly express the typing contract. This change doesn't solve all of those gaps, but does make progress towards that goal.

The maintainers of typeshed have provided an incredibly useful alternative for Requests typing for quite a while. There are some limitations of type stubs that can't fully express the Requests API though. Cases like iter_content having different return typing based on input, or PreparedRequests needing to be state checked can't be easily expressed. Inline annotations let us own the type contract alongside the code and should help drive better development decisions in the future.

For most use cases, the public API should be accurately typed. There are some internals that are currently suppressed because they need discussion, but the package passes pyright strict mode without errors. We've flagged where the sharp edges are and can refine those over time.

The main goal is to start this migration with community input to make sure we're covering all usage. The "magic" of Requests is that it's very flexible, which also means people use it in creative ways the maintainers don't always anticipate. We want to identify those edge cases early in this process before the types ship in a release.

What's changed

  • Every class, function, method, and module-level variable in src/requests/ is annotated
  • No public API changes
  • Passes typechecking with pyright strict mode
  • New internal _types.py module for shared type aliases and Protocols
  • from __future__ import annotations applied everywhere
  • There are some minor bug fixes included, but they address legitimate defects in Requests that resulted in unhandled runtime exceptions.

Design philosophy

Types should describe the code, not change behavior. This was the primary goal in this process, not adding new constraints.

Where typing forced a choice between runtime strictness and backward compatibility, we chose backward compatibility. That's the lens I'd view this through when things may differ from expectations.

How to test

Install from the branch:

python -m pip install git+https://github.com/psf/requests.git@inline_types_rfc

or

uv pip install git+https://github.com/psf/requests.git@inline_types_rfc

Then:

  • Run your existing test suite
  • Run pyright or mypy against your code that uses Requests
  • Try your usual Requests patterns and see if anything doesn't typecheck

What feedback we're looking for

Important

We want feedback from people who actively maintain projects that depend on Requests or use it heavily. Please share your real experience testing this against your code.

Comments that are clearly AI-generated will be hidden or removed. This is a library written "for Humans". The conversation is between maintainers and users, not a prompt.

If you do find issues, we want to hear about it. Specifically:

  • Compatibility: Does this break anything in your project? Especially if you're doing something unusual with Requests internals.
  • Type accuracy: Do the types match how you actually use the API? Are return types too narrow or too wide?
  • Missing coverage: Is there a pattern you use that we're not covering?

We're not looking for style opinions on the annotations themselves or nitpicks on suppression comments. Those are tracked on the PR.

Known limitations

  • str | bytes URL handling: Requests has historically accepted both str and bytes for URLs. The typing currently declares str only. This is a known gap we're still working through.
  • urllib3 stub gaps: Some type: ignore comments exist because urllib3's type stubs don't fully cover all the methods we use.
  • Optional dependency typing: chardet/charset_normalizer detection is imperfect under strict mode since these are optional imports.
  • RequestsCookieJar and MutableMapping: The RequestsCookieJar's conformance to MutableMapping has some known mismatches (extended method signatures, __iter__ yielding Cookie objects instead of strings) that date back to when the class was first written against the Python 2.6 ABC contract. The current types don't fix that, but should be clearer about what the class does. Future work may be able to refine this further.

For more details and rationale, see the PR.

Activity

  1. pinned this issue on Mar 18, 2026
  2. added this to the Soon™ milestone on Mar 18, 2026
  3. bastimeyer commented on Mar 25, 2026

    @bastimeyer
    Contributor

    Thanks for the efforts of adding inline type annotations.

    Here are some issues I've encountered after installing requests from its inline_types_rfc git branch and removing types-requests from my Python environment while using both mypy and ty in my project. I'm including suff that's already listed in the known issues above.

    • Response.status_code is annotated as int | None, whereas typeshed simply annotates it as int. With None included in a type union, this introduces several typing issues in trivial code snippets:

      error[unsupported-operator] Operator >= is not supported between objects of type int | None and Literal[400]

      res = session.get(...)
      if 400 <= res.status_code < 500: ...

      Does it make sense annotating it as int | None just because it's initialized as None?

    • Session.request() and the various HTTP-verb methods (get, post, head, options, delete, put, patch) all differ from typeshed's definitions. Since my project extends Session.request() and the various methods, this causes compatibility issues, but I hadn't had a deeper look at the exact changes yet

    • Session.headers is not a MutableMapping (unlike typeshed), leading to typing issues when trying to set the attribute directly (e.g. session.headers = {})

    • Response.iter_lines str/bytes overload issue:

      error[invalid-assignment] Object of type filter[str | bytes] is not assignable to Iterator[str]

      lines: Iterator[str]
      lines = iter(filter(bool, res.iter_lines(decode_unicode=True)))
    • requests.compat.chardet typing issues: we have a compatibility import for charset_normalizer, based on what requests imports, like this:

      try:
          from requests.compat import chardet as charset_normalizer  # type: ignore
      except ImportError:  # pragma: no cover
          import charset_normalizer
      
      detect_encoding = charset_normalizer.detect

      This now results in ty emitting this error:

      error[unresolved-attribute] Attribute detect is not defined on None in union ModuleType | None

  4. nateprewitt commented on Mar 25, 2026

    @nateprewitt
    MemberAuthor

    Thanks for taking the time to look through the types, @bastimeyer!

    Response.status_code is annotated as int | None, whereas typeshed simply annotates it as int. With None included in a type union, this introduces several typing issues in trivial code snippets:

    That's a fair callout. Requests has this issue with Request, PreparedRequest, and Response. You'll see _ValidatedRequest as an attempt to model the state of a PreparedRequest that was actually initialized by prepare() instead of manually. The Response case has this too, but it looks like that wasn't encapsulated as well as it could. A bare Response() will be None, and mocking tooling does that. Whether that's intended usage, is another question. We may need another TypeGuard to constrain a narrow version of Response returned from the APIs, or take the approach Typeshed did and suppress the errors on a bare Response.

    Session.request() and the various HTTP-verb methods (get, post, head, options, delete, put, patch) all differ from typeshed's definitions.

    I'm assuming this is typeshed's practice of materializing all kwargs directly on every function signature? There's been some discussion about that already here. Once we solve the Unpack layering, I think this will go away. Let me know if I'm missing something though.

    Session.headers is not a MutableMapping (unlike typeshed), leading to typing issues when trying to set the attribute directly (e.g. session.headers = {})

    This one is interesting but not surprising. I originally had MutableMapping here but had to constrain it to CaseInsensitiveDict. That's both what it's always been documented as being, and a runtime requirement for multiple code paths. You'll end up with inaccurate comparisons (and potentially incorrect headers) if you're using a bare dict. I think we want to keep this one so we're not subtly misleading users. It looks like someone got (reasonably) confused, and changed this incorrectly in typeshed.

    Response.iter_lines str/bytes overload issue:

    This one is actually correct. Your response with decode_unicode=True is not guaranteed to be Iterable[str]. If Requests cannot determine the encoding, it falls back to bytes. Typing it strictly as Iterably[str] is not true at runtime and is a vector for breakages if your code assumes that.

    requests.compat.chardet typing issues: we have a compatibility import for charset_normalizer, based on what requests imports, like this:

    This one is also subtle. If neither charset_normalizer or chardet are installed at runtime, you will get back None from your import. I don't think you can actually hit your ImportError fallback in this case? There isn't a released version of Requests that runs on Python 3.9+ that doesn't have the import available. That said, I don't think Requests can actually run if neither is present, so I think this just becomes a runtime error in most cases. I'm open to constraining it to module type, it's just going to be slightly incorrect. Requests does round trip successfully without either character detection library so this is a possibility, although I'd consider it obscure/not generally supported.

  5. modified the milestones: Soon™, 2.34.0 on Apr 4, 2026
  6. nateprewitt commented on Apr 29, 2026

    @nateprewitt
    MemberAuthor

    I've moved the PR from Draft to Ready for Review. While there may be minor refactors in the coming week or two, I think the changes are largely done. It's safe to assume time for comment will be closing in the coming weeks, please take a look now if you intend to do so.

  7. nateprewitt commented on May 3, 2026

    @nateprewitt
    MemberAuthor

    A pre-release (2.34.0.dev1) is now available on PyPI for easier install.

  8. edgarrmondragon commented on May 7, 2026

    @edgarrmondragon

    Hey Nate, thanks for publishing that pre-release!

    The class-level attribute annotations on Response are causing issues for downstream projects that subclass it as a dataclass or similar. Other classes in models.py surely have the same problem. It does seem that enabling forward references is the culprit downstream, so I wonder if this something that should be addressed here.

    For example, in requests-cache:

    @define(auto_attribs=False, repr=False, slots=False)
    class BaseResponse(Response):
        """Wrapper class for responses returned by :py:class:`.CachedSession`. This mainly exists to
        provide type hints for extra cache-related attributes that are added to non-cached responses.
        """

    source: https://github.com/requests-cache/requests-cache/blob/5ca431fefa89001e09fd22da642867248aea2a01/requests_cache/models/response.py#L28-L32

    FWIW moving the annotations to the attribute assignments inside __init__ seems to fix the problem.


    Here's a MRE

    # /// script
    # dependencies = [
    #   "requests==2.34.0.dev1",
    #   "requests-cache==1.3.1",
    # ]
    # ///
    
    import requests_cache
    
    session = requests_cache.CachedSession('demo_cache')
    for i in range(60):
        session.get('https://httpbin.org/delay/1')
    Traceback

    uv run --script main.py

    Installed 11 packages in 10ms
    Traceback (most recent call last):
      File "/Users/me/Code/repro/requests-234/main.py", line 12, in <module>
        session.get('https://httpbin.org/delay/1')
        ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/session.py", line 133, in get
        return self.request('GET', url, params=params, **kwargs)
               ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/session.py", line 189, in request
        return super().request(method, url, *args, headers=headers, **kwargs)  # type: ignore
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests/sessions.py", line 651, in request
        resp = self.send(prep, **send_kwargs)
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/session.py", line 224, in send
        cached_response = self.cache.get_response(actions.cache_key)
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/backends/base.py", line 69, in get_response
        response = self.responses.get(key)
      File "<frozen _collections_abc>", line 795, in get
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/backends/sqlite.py", line 338, in __getitem__
        return self.deserialize(key, row[0])
               ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/backends/base.py", line 354, in deserialize
        obj = self.serializer.loads(value)
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/serializers/pipeline.py", line 72, in loads
        value = step(value)
      File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/functools.py", line 1069, in __call__
        return self._dispatch(args[0].__class__).__get__(self._obj, self._cls)(*args, **kwargs)
               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/requests_cache/serializers/cattrs.py", line 88, in _
        return _encode_content(self.converter.structure(value, cl=CachedResponse))
                               ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/converters.py", line 591, in structure
        return self._structure_func.dispatch(cl)(obj, cl)
               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/dispatch.py", line 133, in dispatch_without_caching
        res = self._function_dispatch.dispatch(typ)
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/dispatch.py", line 76, in dispatch
        return handler(typ)
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/cattrs/converters.py", line 1308, in gen_structure_attrs_fromdict
        resolve_types(origin or cl)
        ~~~~~~~~~~~~~^^^^^^^^^^^^^^
      File "/var/folders/k7/l2cj2kv14x9dg7ljk_p20y7w0000gn/T/.tmprZUSbx/environments-v2/main-d18e81ec503134bc/lib/python3.14/site-packages/attr/_funcs.py", line 487, in resolve_types
        hints = typing.get_type_hints(cls, **kwargs)
      File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/typing.py", line 2391, in get_type_hints
        value = _eval_type(value, base_globals, base_locals, (),
                           format=format, owner=obj, prefer_fwd_module=True)
      File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/typing.py", line 461, in _eval_type
        return evaluate_forward_ref(t, globals=globalns, locals=localns,
                                    type_params=type_params, owner=owner,
                                    _recursive_guard=recursive_guard, format=format)
      File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/typing.py", line 1000, in evaluate_forward_ref
        value = forward_ref.evaluate(globals=globals, locals=locals,
                                     type_params=type_params, owner=owner, format=format)
      File "/Users/me/Library/Application Support/uv/python/cpython-3.14.4-macos-aarch64-none/lib/python3.14/annotationlib.py", line 201, in evaluate
        raise NameError(_NAME_ERROR_MSG.format(name=arg), name=arg)
    NameError: name 'RequestsCookieJar' is not defined

  9. nateprewitt commented on May 8, 2026

    @nateprewitt
    MemberAuthor

    Thanks for the repro, @edgarrmondragon. This is an unfortunately messy side-effect I was not aware of. Presumably this would apply to most dataclass style libraries beyond attrs?

    I don't have a strong reason why these were added at class-level other than consistency. Not all attributes can be moved to __init__. Some like Response.connection or RequestsCookieJar._policy aren't actually set in the __init__ (or don't have an explicit __init__). I tried in this commit but these two top-level attributes are stuck where they are.

    FWIW moving the annotations to the attribute assignments inside init seems to fix the problem.

    Given the above, I'm curious how you aren't hitting errors for HTTPAdapter with connection. Do you have an example of what you tried?

  10. edgarrmondragon commented on May 8, 2026

    @edgarrmondragon

    Given the above, I'm curious how you aren't hitting errors for HTTPAdapter with connection. Do you have an example of what you tried?

    If I move RequestsCookieJar outside of the TYPE_CHECKING block, then I do get a similar error for HTTPAdapter

    Some like Response.connection or RequestsCookieJar._policy aren't actually set in the __init__ (or don't have an explicit __init__).

    I gave it a try locally and it seems you can still annotate it within __init__:

    class Response:
        def __init__(self, ...) -> None:
           self.connection: HTTPAdapter

    I created a PR with the small diff I tried out locally, which fixed my requests-cache example above:

  11. 5 remaining items

  12. bastimeyer commented on May 11, 2026

    @bastimeyer
    Contributor

    I've noticed some issues with the current definition of ParamsType. I haven't followed this thread since my last post after which I fixed all the issues I ran into, so I'm not sure if this has been brought to attention yet.

    ParamsType currently defines Mapping[_ParamsMappingKeyType, _ParamsMappingValueType] as one of the union items, which is a problem, because the key type is invariant in Mapping.

    The first example works, while the second one results in typing issues (checked via ty/mypy):

    import requests
    requests.get("http://localhost", params={"foo": "bar"})
    import requests
    params={"foo": "bar"}
    requests.get("http://localhost", params=params)
    $ ty check foo.py
    error[invalid-argument-type]: Argument to function `get` is incorrect
     --> foo.py:3:34
      |
    3 | requests.get("http://localhost", params=params)
      |                                  ^^^^^^^^^^^^^ Expected `Mapping[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None] | Iterable[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]] | str | bytes | None`, found `dict[str, str]`
      |
    info: type `dict[str, str]` is not assignable to any element of the union `Mapping[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None] | Iterable[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]] | str | bytes | None`
    info: ├── element `bytes` of union `str | bytes | int | float` is not assignable to `str`
    info: ├── type `dict[str, str]` is not assignable to protocol `Iterable[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]]`
    info: │   └── protocol member `__iter__` is incompatible
    info: │       └── incompatible return types: `Iterator[str]` is not assignable to `Iterator[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]]`
    info: │           └── protocol `Iterator[str]` is not assignable to protocol `Iterator[tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]]`
    info: │               └── protocol member `__next__` is incompatible
    info: │                   └── incompatible return types: `str` is not assignable to `tuple[str | bytes | int | float, Iterable[str | bytes | int | float] | float | int | None]`
    info: └── ... omitted 3 union elements without additional context
    

    Typeshed defines it with a SupportsItems protocol, similar to yours:

  13. nateprewitt commented on May 11, 2026

    @nateprewitt
    MemberAuthor

    Great find, @bastimeyer! I've put up #7426 which should match more closely to what typeshed is doing and it brings the API closer to our Iterable inputs. Let me know if you think anything else is missing, otherwise we'll plan to merge that change.

  14. bastimeyer commented on May 11, 2026

    @bastimeyer
    Contributor

    Let me know if you think anything else is missing

    The SupportsItems Protocol changes in #7426 seem fine. At least they fix my issue when installing from that PR branch. Thanks.

    But what I just noticed is, why is there a dedicated GetKwargs that's excluding the params keyword from GET requests? GET requests should also allow params, so query strings can be built from a mapping. RequestKwargs includes it, but it's explicitly not used for get().

    class RequestKwargs(BaseRequestKwargs, total=False):
    """kwargs for request(), options(), head(), delete()."""
    params: ParamsType
    data: DataType
    json: JsonType
    class GetKwargs(BaseRequestKwargs, total=False):
    data: DataType
    json: JsonType

  15. nateprewitt commented on May 11, 2026

    @nateprewitt
    MemberAuthor

    Also a good find, there's actually asymmetry on the Session.get() API vs the requests.get() API. The kwarg types were built on api.py where params is explicit and then ported over to session.py which is missing the same parameter. I'll get that cleaned up.

    Edit: Should be addressed in #7429.

  16. nateprewitt commented on May 11, 2026

    @nateprewitt
    MemberAuthor
  17. gothicVI commented on May 12, 2026

    @gothicVI

    With Response.reason being explicitly initialized as None and typed accordingly as union of str | None this breaks type checkers that infer their types mainly from the source like pyright. This happens in our code base where we have custom exceptions we raise and that get initialized with the reason as a str.

    Interestingly with types-requests installed mypy, ty, and pyright are fine with us simply passing response.reasoninto our custom exception. Onlypyright` complains about

    error: Argument of type "str | None" cannot be assigned to parameter "response_reason" of type "str" in function "__init__"
    

    Are you planning to address this or should we handle this?

    I also noticed that in the Response.raise_for_status method there's no check for None such that the reason variable in the http_error_msg could be printed as empty.

  18. nateprewitt commented on May 12, 2026

    @nateprewitt
    MemberAuthor

    Thanks for the report, @gothicVI. Reason is inherently optional (even at the HTTP Protocol level) and as you noted initialized as None. That said, we've smoothed this out for other Response attributes like status_code where it's almost never None. Let me think about this one for a bit today and we'll make a decision on if we clean this up in 2.34.1. Initial testing with urllib3 is showing they are normalizing missing values to '' which is the primary path Requests users are going through.

  19. unpinned this issue on Jun 5, 2026
  20. nateprewitt commented on Jun 5, 2026

    @nateprewitt
    MemberAuthor

    Closing this out since we haven't had any notable feedback in the last few weeks. Anything new can be raised as its own issue. Thanks again everyone who gave feedback and help with reviewing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions