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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions