@@ -583,26 +583,17 @@ static void ArrowArrayStreamPyCapsuleDestructor(PyObject *object) {
583583 delete stream;
584584}
585585
586- // Destructor for capsules pointing at an embedded ArrowArrayStream (fast path).
587- // The stream is owned by an ArrowQueryResultStreamWrapper; Release() frees both.
588- static void ArrowArrayStreamEmbeddedPyCapsuleDestructor (PyObject *object) {
589- auto data = PyCapsule_GetPointer (object, " arrow_array_stream" );
590- if (!data) {
591- return ;
592- }
593- auto stream = reinterpret_cast <ArrowArrayStream *>(data);
594- if (stream->release ) {
595- stream->release (stream);
596- }
597- }
598-
599586py::object DuckDBPyResult::FetchArrowCapsule (idx_t rows_per_batch) {
600587 if (result && result->type == QueryResultType::ARROW_RESULT) {
601588 // Fast path: yield pre-built Arrow arrays directly.
602589 // The wrapper is heap-allocated; Release() deletes it via private_data.
603- // The capsule points at the embedded stream field — no separate heap allocation needed.
590+ // We heap-allocate a separate ArrowArrayStream for the capsule so that the capsule
591+ // holds a stable pointer even after the wrapper is consumed and deleted by a scan.
604592 auto wrapper = new ArrowQueryResultStreamWrapper (std::move (result));
605- return py::capsule (&wrapper->stream , " arrow_array_stream" , ArrowArrayStreamEmbeddedPyCapsuleDestructor);
593+ auto stream = new ArrowArrayStream ();
594+ *stream = wrapper->stream ;
595+ wrapper->stream .release = nullptr ;
596+ return py::capsule (stream, " arrow_array_stream" , ArrowArrayStreamPyCapsuleDestructor);
606597 }
607598 // Existing slow path for MaterializedQueryResult / StreamQueryResult
608599 auto stream_p = FetchArrowArrayStream (rows_per_batch);
0 commit comments