Skip to content

QuerySet.in_bulk type hints do not match runtime behavior for non-str|int fields #2052

@danielfrankcom

Description

@danielfrankcom

Describe the bug

QuerySet.in_bulk’s type hints do not match runtime behavior when field_name refers to a field type that is not str or int. For example UUIDField, BinaryField, DecimalField, etc.

The method is currently annotated roughly as:

  • id_list: Iterable[str | int]
  • return type: dict[str, MODEL]

However, the implementation constructs the result as:

{getattr(obj, field_name): obj for obj in objs}

This means the dictionary keys are typed values, not necessarily strings.

As a result:

  • The input typing rejects valid runtime inputs such as uuid.UUID, bytes, Decimal, etc.
  • The return typing is incorrect for non-str fields.
  • In some cases the typing is actively misleading. id_list elements are converted via the field’s to_db_value. For something like BinaryField, passing str (allowed by typing) can raise TypeError: string argument without an encoding, while passing bytes works but is rejected according to the types.

Field types likely affected:

Any field where the Python value type is not str, or where str is not reliably coercible, could encounter this issue.

  • UUIDField -> uuid.UUID
  • BinaryField -> bytes
  • DecimalField -> decimal.Decimal
  • DatetimeField -> datetime.datetime
  • DateField -> datetime.date
  • TimeField -> datetime.time
  • TimeDeltaField -> datetime.timedelta
  • FloatField -> float

The core issue seems to be generic, since keys are getattr(obj, field_name).

To Reproduce

  1. Define a model whose primary key has a non-str|int Python type, for example UUIDField.
  2. Create a single row and observe the Python type of the primary key value.
  3. Call in_bulk using the native Python value of the field and inspect the returned mapping’s key type.

Conceptual example:

item = await Item.create(...)
bulk = await Item.all().in_bulk([item.id], "id")
print(type(next(iter(bulk.keys()))))

Observe that:

  • item.id is not str (e.g. uuid.UUID)
  • in_bulk([item.id], ...) works at runtime
  • the returned mapping keys have the same non-str type (e.g. uuid.UUID)

This contradicts the current type annotation, which specifies a return type of dict[str, MODEL] and restricts inputs to Iterable[str | int].

A self-contained reproducer using `uv` can be found in this spoiler tag
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["tortoise-orm"]
# ///

import asyncio
import uuid

from tortoise import Tortoise, fields
from tortoise.models import Model


class Item(Model):
    id = fields.UUIDField(primary_key=True, default=uuid.uuid4)
    name = fields.CharField(max_length=100)

    class Meta:
        table = "items"


async def main() -> None:
    await Tortoise.init(db_url="sqlite://:memory:", modules={"models": [__name__]})
    await Tortoise.generate_schemas()

    item = await Item.create(name="Test")

    print("Item.id type :", type(item.id))

    print("\nCall #1: pass str(UUID), allowed by typing")
    bulk_str = await Item.all().in_bulk([str(item.id)], field_name="id")
    key_str = next(iter(bulk_str.keys()))
    print("Returned key type:", type(key_str))
    print("Note above does not match expected str return")

    print("\nCall #2: pass UUID directly, rejected by typing but still works")
    bulk_uuid = await Item.all().in_bulk([item.id], field_name="id")
    key_uuid = next(iter(bulk_uuid.keys()))
    print("Returned key type:", type(key_uuid))
    print("Note above also does not match expected str return")

    await Tortoise.close_connections()


if __name__ == "__main__":
    asyncio.run(main())

Expected behavior

Type hints should reflect actual behavior:

  • id_list should accept the native Python value type for the field, not only str | int.
  • The returned mapping key type should match the field’s Python value type, not always str.

Additional context

This appears to be a typing/annotation issue rather than a runtime bug. The current runtime behavior of in_bulk seems to be consistent with expected value conversions (UUIDField -> uuid.UUID, BinaryField -> bytes, DecimalField -> Decimal, etc.).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions