The Auxiliary Buffer: How a DuckDB Vector Owns Memory It Never Allocated
A sliced DuckDB vector points into its parent's bytes and carries a shared_ptr that keeps the parent alive. Here is the type that makes it safe.
TL;DR: A
VectorBufferowns its data bytes. But it can also own other people’s bytes without copying them, by stashing a polymorphicAuxiliaryDataHolderin anAuxiliaryDataSet. The star example: slicing a vector is a pointer bump (data_ptr + type_size * offset) plus ashared_ptrto the parent, so the parent cannot die while the child points into it. No memcpy, no dangling slice — the lifetime is enforced by the type system.The same mechanism keeps string heaps, pinned storage blocks, and selection-vector data alive across every copy, slice and filter in the engine.
The 16 KiB copy you don’t have to do
Here is a situation the engine hits constantly.
A scan produces a chunk of 2048 rows. A filter runs over it. The filter’s output is the subset of rows that passed — say 37 of them. Downstream operators need a chunk containing those 37 rows.
The naive implementation copies 37 rows into a fresh buffer. That is fine. Now consider what actually happens in a real query: WHERE clauses stack, projections narrow columns, joins combine chunks, and every one of those steps produces a sub-range of the previous chunk. If every step copies, a query over a billion rows does a billion rows’ worth of memcpy, and the engine spends its time in memcpy instead of in the user’s aggregation.
DuckDB’s answer: a sub-range of a chunk is just a chunk whose data pointer starts later. Here is the entire implementation:
// src/common/vector/flat_vector.cpp:97
buffer_ptr<VectorBuffer> StandardVectorBuffer::SliceInternal(const LogicalType &type, idx_t offset, idx_t end) {
D_ASSERT(end <= capacity);
auto count = count_t(end - offset);
auto offset_ptr = data_ptr + type_size * offset;
auto result = make_buffer<StandardVectorBuffer>(offset_ptr, count, type_size);
result->GetValidityMask().Slice(validity, offset, count);
result->AddAuxiliaryData(make_uniq<VectorBufferHolder>(shared_from_this()));
return result;
}Four meaningful lines:
offset_ptr = data_ptr + type_size * offset— the new buffer points into the old buffer’s allocation. No data is copied.make_buffer<StandardVectorBuffer>(offset_ptr, count, type_size)— a newVectorBufferobject is created that wraps that pointer. Note the constructor takes a rawdata_ptr, not an allocation, so the new buffer does not own the bytes.result->GetValidityMask().Slice(validity, offset, count)— the NULL bitmap is sliced the same way: a view, not a copy.result->AddAuxiliaryData(make_uniq<VectorBufferHolder>(shared_from_this()))— and here is the interesting part.
Step 4 is the entire subject of this article.
The problem with step 4
The new buffer points into the old buffer’s memory. If the old buffer is freed, the new buffer is a dangling pointer, and reading it gives you garbage — or a segfault, or (much worse) plausible-looking wrong answers.
C++’s usual answer is to copy the data. Copying is correct and expensive.
DuckDB’s answer is: make it impossible for the old buffer to be freed while the new one exists. The way to do that in C++ is to hold a shared_ptr to it. And since the parent VectorBuffer already inherits enable_shared_from_this<VectorBuffer>, the parent can hand out a reference to itself:
// src/include/duckdb/common/types/vector_buffer.hpp:76
class VectorBufferHolder : public AuxiliaryDataHolder {
public:
explicit VectorBufferHolder(buffer_ptr<VectorBuffer> buffer_p) : buffer(std::move(buffer_p)) {
}
private:
buffer_ptr<VectorBuffer> buffer;
};The child buffer stores a shared_ptr<VectorBuffer> to its parent. As long as the child lives, the parent’s refcount is at least one and the parent’s destructor cannot run. When the child dies, the refcount drops and the parent is freed if nobody else wants it.
The lifetime is now a property of the type system, not of programmer discipline. You cannot forget to keep the parent alive, because keeping it alive is what the constructor does.
The auxiliary data hierarchy
VectorBufferHolder is one member of a small family. Here is the whole family:
// src/include/duckdb/common/types/vector_buffer.hpp:46
struct AuxiliaryDataHolder {
virtual ~AuxiliaryDataHolder() = default;
virtual idx_t GetAllocationSize() const {
return 0;
}
};
struct AuxiliaryDataSet {
vector<unique_ptr<AuxiliaryDataHolder>> data;
};
class PinnedBufferHolder : public AuxiliaryDataHolder {
public:
explicit PinnedBufferHolder(BufferHandle handle);
~PinnedBufferHolder() override;
private:
BufferHandle handle;
};
class AuxiliaryDataSetHolder : public AuxiliaryDataHolder {
public:
explicit AuxiliaryDataSetHolder(buffer_ptr<AuxiliaryDataSet> buffer) : auxiliary_data(std::move(buffer)) {
}
private:
buffer_ptr<AuxiliaryDataSet> auxiliary_data;
};The design is a classic type-erased bag:
AuxiliaryDataHolderis an abstract base with a virtual destructor and an optionalGetAllocationSize()hook so that memory accounting can walk the bag.AuxiliaryDataSetis avector<unique_ptr<AuxiliaryDataHolder>>— the bag itself, heap-allocated on demand.AuxiliaryDataSetHolderholds a whole bag, not a single item. That is the “share my parent’s bag” holder.
And the bag hangs off the buffer:
// src/include/duckdb/common/types/vector_buffer.hpp:119
void AddAuxiliaryData(unique_ptr<AuxiliaryDataHolder> aux_data_p) {
if (!auxiliary_data) {
auxiliary_data = make_buffer<AuxiliaryDataSet>();
}
auxiliary_data->data.push_back(std::move(aux_data_p));
}
const buffer_ptr<AuxiliaryDataSet> &GetAuxiliaryData() const {
return auxiliary_data;
}
virtual void ClearAuxiliaryData() {
auxiliary_data.reset();
}Note the lazy allocation: the bag is only created when something is actually put into it. A plain INTEGER column with no strings and no slices has no AuxiliaryDataSet at all — just a null buffer_ptr.
Three kinds of thing you can hold
1. A parent vector buffer (VectorBufferHolder). Used by slicing, as above.
2. Another buffer’s entire auxiliary set (AuxiliaryDataSetHolder). This is how string heaps travel.
When DuckDB stores a VARCHAR column, the 2048 string_t structs live in the vector buffer, but the characters live in a separate heap. If a string is longer than 12 bytes it is a pointer into that heap; if it is shorter it is inlined in the string_t itself.
Now slice a string vector. The string_t array can be sliced with a pointer bump, but the heap cannot — it is a separate allocation that the parent owns. So the child needs to keep the parent’s heap alive:
// src/common/vector/string_vector.cpp:139
buffer_ptr<VectorBuffer> VectorStringBuffer::SliceInternal(const LogicalType &type, idx_t offset, idx_t end) {
auto type_size = GetTypeIdSize(type.InternalType());
auto offset_ptr = data_ptr + type_size * offset;
auto count = count_t(end - offset);
auto result = make_buffer<VectorStringBuffer>(offset_ptr, count);
result->GetValidityMask().Slice(validity, offset, count);
result->AddAuxiliaryData(make_uniq<VectorBufferHolder>(shared_from_this()));
return result;
}Same shape as the flat case — but note that VectorStringBuffer’s own auxiliary set contains the string heap (a StringHeapHolder). The child now holds a VectorBufferHolder pointing at the parent, and the parent’s bag contains the heap. One level of indirection keeps the whole chain alive.
3. A pinned storage block (PinnedBufferHolder). This one is different in kind: it holds a BufferHandle, which is DuckDB’s buffer-manager pin. Pinning a block prevents the buffer manager from evicting it. Putting a BufferHandle inside a vector means that as long as the vector is alive, the storage block stays pinned and mapped.
// src/common/vector/string_vector.cpp:235
void StringVector::AddAuxiliaryData(Vector &vector, unique_ptr<AuxiliaryDataHolder> data) {
vector.AddAuxiliaryData(std::move(data));
}
void StringVector::AddHandle(Vector &vector, BufferHandle handle) {
AddAuxiliaryData(vector, make_uniq<PinnedBufferHolder>(std::move(handle)));
}
void StringVector::AddHeapReference(Vector &vector, const Vector &other) {
vector.AddHeapReference(other);
}AddHandle is the entry point: a vector that reads strings directly out of a Parquet or CSV buffer (rather than copying them into a heap) attaches the pin to the vector. When the vector is recycled, the pin is released with it. The buffer manager never has to be told explicitly.
Walking through dictionaries
There is a subtlety with AddHeapReference. If the source vector is a dictionary vector, the heap it actually uses belongs to the dictionary’s child, not to the dictionary itself. So the reference has to be chased:
// src/common/types/vector.cpp:303
void Vector::AddHeapReference(const Vector &other) {
if (other.GetVectorType() == VectorType::DICTIONARY_VECTOR) {
AddHeapReference(DictionaryVector::Child(other));
return;
}
auto &auxiliary_data = other.buffer->GetAuxiliaryData();
if (!auxiliary_data) {
return;
}
AddAuxiliaryData(make_uniq<AuxiliaryDataSetHolder>(auxiliary_data));
}Three lines of recursion, then the interesting bit: the target gets an AuxiliaryDataSetHolder pointing at the source’s entire auxiliary set. Not a copy of the heap — a reference to the bag that contains it.
This is why the bag is shareable as a unit. Rather than enumerate “the things a vector might need to keep alive” and copy each one, DuckDB says: “here is the whole bag; hold onto it.” The bag is reference-counted (buffer_ptr<AuxiliaryDataSet>), so this is one refcount bump, not a deep copy of the contents.
Where the slicing actually happens
The buffer-level Slice is where the type dispatch happens, and it is worth reading because of how it handles the constant case:
// src/common/types/vector_buffer.cpp:200
buffer_ptr<VectorBuffer> VectorBuffer::Slice(const LogicalType &type, idx_t offset, idx_t end) {
if (vector_type == VectorType::CONSTANT_VECTOR) {
// constant vectors do not need to get sliced - but we do need to update the count
return ConstantSlice(type, count_t(end - offset));
}
auto result = SliceInternal(type, offset, end);
if (result) {
if (result->Size() != end - offset) {
throw InternalException("Slice with offset,end did not set size correctly");
}
}
return result;
}A constant vector has one physical value, so “slicing” it cannot mean “take a pointer offset” — there is nothing to offset. It means “keep the same pointer, but declare that there are now N logical rows”. Which is exactly what ConstantSliceInternal does, and note that it also attaches a VectorBufferHolder to the parent, for the same lifetime reason:
// src/common/vector/flat_vector.cpp:107
buffer_ptr<VectorBuffer> StandardVectorBuffer::ConstantSliceInternal(const LogicalType &type, count_t count) {
// a constant has one physical entry - the logical count is tracked separately from the physical capacity
auto result = make_buffer<StandardVectorBuffer>(data_ptr, count_t(1ULL), type_size);
result->GetValidityMask().Set(0, validity.RowIsValid(0));
result->SetVectorType(VectorType::CONSTANT_VECTOR);
result->SetVectorSize(count);
result->AddAuxiliaryData(make_uniq<VectorBufferHolder>(shared_from_this()));
return result;
}Notice count_t(1ULL) — the new buffer’s capacity is one element, while SetVectorSize(count) sets its logical size to count. Physical capacity and logical cardinality are decoupled for constants. A constant vector of a billion rows is one slot of memory.
And at the Vector level, the slice is a one-liner:
// src/common/types/vector.cpp:209
void Vector::Slice(const Vector &other, idx_t offset, idx_t end) {
buffer = other.buffer->Slice(GetType(), offset, end);
if (!buffer) {
buffer = other.buffer;
}
}Which brings us back to the whole design in one line: buffer is a buffer_ptr<VectorBuffer>, so replacing it is a refcount operation, and the vector’s data pointer now points into somebody else’s allocation.
Seeing it yourself
The auxiliary set is invisible from SQL, but you can provoke the paths that use it:
CREATE TABLE strings AS SELECT 'prefix-' || range AS s FROM range(100000);
-- every row allocates into a string heap; the heap must survive slicing,
-- filtering and projection.
SELECT length(s) FROM strings WHERE s LIKE 'prefix-1%' LIMIT 5;
-- a constant on the left, a flat vector on the right: the constant is a
-- one-element buffer whose logical size is the whole chunk.
SELECT count(*) FROM strings WHERE 1 = 1;
-- strings that fit inline (<= 12 bytes) never touch the heap at all.
SELECT count(*) FROM strings WHERE length(s) < 13;The last two queries are the interesting contrast. 1 = 1 produces a CONSTANT_VECTOR on the left, and the comparison runs against a one-slot buffer. length(s) < 13 works on string_t structs whose payload is inline in the struct, so no heap is touched at all.
The fourth kind of thing: selection data
A dictionary vector’s payload is not the values — those live in the child. Its payload is the selection vector: a uint32_t per row saying which child row to look at. And a selection vector is sometimes heap-allocated.
So the same trick applies. When a dictionary vector is sliced with a non-zero offset, the new buffer’s selection vector is a pointer into the old one’s array — again a view, not a copy:
// src/common/vector/dictionary_vector.cpp:99
buffer_ptr<VectorBuffer> DictionaryBuffer::SliceInternal(const LogicalType &type, idx_t offset, idx_t end) {
// dictionary vector slice: slice the dictionary instead of stacking dictionaries
if (type.InternalType() == PhysicalType::STRUCT) {
throw InternalException("Struct vectors cannot be dictionary vectors");
}
auto count = end - offset;
auto &sel_data = GetSelVector().sel_data();
if (!sel_data) {
// non-owning sel, we need to create a new selection vector to slice
SelectionVector new_sel(count);
for (idx_t i = 0; i < count; i++) {
new_sel.set_index(i, sel_vector.get_index(offset + i));
}
return make_uniq<DictionaryBuffer>(new_sel, count, entry);
}
if (offset == 0) {
// for offset = 0 all we have to do is update the count - so just create a new buffer
return make_uniq<DictionaryBuffer>(sel_data, end, entry);
}
SelectionVector sliced_sel(sel_vector.data() + offset, count);
auto result = make_uniq<DictionaryBuffer>(sliced_sel, count, entry);
result->AddAuxiliaryData(make_uniq<SelectionDataHolder>(sel_data));
return result;
}Line 120 is the one to notice: make_uniq<SelectionDataHolder>(sel_data) — the new buffer keeps the allocation that owns the selection vector’s uint32_t array alive, because it now only holds a pointer into the middle of it.
The holder itself is the thinnest possible version of the pattern:
// src/include/duckdb/common/vector/dictionary_vector.hpp:106
class SelectionDataHolder : public AuxiliaryDataHolder {
public:
explicit SelectionDataHolder(buffer_ptr<SelectionData> selection_data_p)
: selection_data(std::move(selection_data_p)) {
}
private:
buffer_ptr<SelectionData> selection_data;
};No destructor override, no extra behaviour. Just “I hold a reference to this allocation”. That is the whole abstraction: AuxiliaryDataHolder is a bag of “things my bytes depend on”, and every one of them is a shared_ptr in disguise.
Where the pattern does not apply: the validity mask
The data array can be sliced with a pointer bump because the elements are fixed-width and byte-aligned. The NULL bitmap cannot: a bitmask sliced at row 17 starts in the middle of a 64-bit word.
So the validity mask is the one place where a slice does copy — but only 32 words at most, and only when there is a mask to copy:
// src/common/types/validity_mask.cpp:117
void ValidityMask::Slice(const ValidityMask &other, idx_t source_offset, idx_t count) {
if (other.CannotHaveNull()) {
validity_mask = nullptr;
validity_data.reset();
return;
}
if (source_offset == 0) {
Initialize(other);
return;
}
ValidityMask new_mask(count);
new_mask.SliceInPlace(other, 0, source_offset, count);
Initialize(new_mask);
}Two details are worth stealing even though this is not an auxiliary-data story:
- The
other.CannotHaveNull()early-out means a column that never has NULLs slices its mask in zero time. The absence of a mask is itself information, and it propagates. Initialize(other)on a zero offset still copies — butInitializeshares the underlying buffer when the source has one, so it is a refcount bump, not a copy of the bits.
So the 2048 rows of a vector cost 16 KiB of data plus at most 256 bytes of bitmap, and the bitmap is usually absent entirely.
What it costs
- One heap allocation per holder.
make_uniq<VectorBufferHolder>(shared_from_this())allocates a small object. A slice is not quite free — it is onemallocof ~16 bytes plus ashared_ptrrefcount bump, instead of a 16 KiBmemcpy. - Atomic refcount operations.
shared_ptrcopy and destruction are atomic increments and decrements. On a many-core machine with 64 threads all slicing the same parent buffer, that is a contended cache line. DuckDB accepts this because the alternative (copying 16 KiB) is far worse. - A layer of indirection when the bag is walked. Reading the heap of a sliced string vector requires going through the parent.
- The bag must be cleared when a vector is recycled. Otherwise a recycled vector would keep a stale pin or a stale parent alive. That is what
ClearAuxiliaryData()is for, and it is called on every chunk reset. - Shared bags mean shared lifetimes. Because
AuxiliaryDataSetHoldershares the entire bag, a child vector keeps alive everything the parent was keeping alive — including pins the child does not need. This is a small over-retention, traded for not having to enumerate what is needed.
The bottom line
The general pattern here is worth stealing: when you need a view onto someone else’s memory, and you cannot copy it, then make the view own a reference to the owner.
DuckDB does that with one polymorphic base class and a vector<unique_ptr<...>>. The consequences:
- Slicing a vector is an O(1) pointer bump, not an O(n) copy.
- A dangling slice is not something you can write; the parent’s
shared_ptrrefcount makes it impossible. - The same mechanism extends to string heaps, pinned storage blocks, and selection-vector data, with no extra code in the callers.
- The cost is one small allocation and an atomic refcount per slice — paid in preference to 16 KiB of memory traffic.
Ownership, expressed in the type system, is cheaper than copying.