Skip to content

bug: AioModelInsert.fetch_results returns incorrect data when using RETURNING with multi-row inserts (breaks bulk_create behavior) #328

@b-simjoo

Description

@b-simjoo

Expected Behavior

When using PostgreSQL and calling an insert with RETURNING, Peewee’s documentation states that newly created primary keys should be populated for each inserted instance. (here)
For multi-row inserts (e.g. bulk operations), PostgreSQL returns one row per inserted record.

Example:

objs = [MyModel(name="a"), MyModel(name="b")]
res = await MyModel.insert_many(
    [("a",), ("b",)],
    fields=[MyModel.name]
).returning(MyModel.id).aio_execute()

# expected:
# [(1,), (2,)]

Actual Behavior

peewee_async returns only the first row and also unwraps the tuple, producing only the scalar primary key of the first record.

This breaks any logic that relies on multi-row returning, including implementing an async version of Peewee’s bulk_create.

For instance, the original implementation:

class AioModelInsert(peewee.ModelInsert, AioQueryMixin):
    async def fetch_results(self, cursor: CursorProtocol) -> Union[List[Any], Any, int]:
        if self._returning is not None and len(self._returning) > 1:
            return await fetch_models(cursor, self)

        if self._returning:
            row = await cursor.fetchone()
            return row[0] if row else None
        else:
            return cursor.lastrowid

Problems in this implementation:

1. fetchone() reads only a single row

PostgreSQL returns one row per inserted record, but fetchone() consumes only the first and discards the rest.

2. Returned tuple is unwrapped (row[0])

Peewee’s synchronous version returns tuples/lists, not raw values.
This produces an inconsistent output shape.

3. Bulk insert becomes impossible to use

Because only the first primary key is returned, and bulk operations need one primary key per instance to populate model objects.


Minimal Reproducible Example

res = await MyModel.insert_many(
    [("a",), ("b",)],
    fields=[MyModel.name]
).returning(MyModel.id).aio_execute()

print(res)

Expected:

[(1,), (2,)]

Actual (peewee_async):

1

(only the first id, unwrapped from its tuple)


Cause of the Bug

The logic handling the RETURNING clause is inconsistent with Peewee’s ModelInsert:

  • multi-row insert → should read all rows
  • single-field returning → should still return list of tuples
  • fetchone() and row[0] deny both invariants

Proposed Fix

This corrected version aligns with Peewee’s synchronous behavior and correctly handles multi-row returning:

class AioModelInsert(peewee.ModelInsert, AioQueryMixin):
    async def fetch_results(self, cursor: CursorProtocol) -> Union[List[Any], Any, int]:
        if isinstance(self._insert,dict):
            row = await cursor.fetchone()
            return row[0] if row else None
        elif self._as_rowcount:
            return cursor.lastrowid
        else:
            return await fetch_models(cursor, self)

Additional Notes

With this fix, multi-row inserts with RETURNING work correctly, enabling functionality equivalent to Peewee’s bulk_create in async workflows without patching the library externally.

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