You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@arrow.apache.org by ze...@apache.org on 2026/09/01 21:16:59 UTC

(arrow-go) branch main updated: feat(extensions): add VariantGet for path extraction from variant arrays (#1206)

This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 2e264f0b feat(extensions): add VariantGet for path extraction from variant arrays (#1206)
2e264f0b is described below

commit 2e264f0bfb1f3cfc788c4208bdb4c8fb40542847
Author: Neelesh Salian <ns...@users.noreply.github.com>
AuthorDate: Tue Sep 1 14:16:53 2026 -0700

    feat(extensions): add VariantGet for path extraction from variant arrays (#1206)
    
    ### Rationale for this change
    
    Reading one field from a variant column today reassembles the whole
    value per row (`VariantArray.Value`) and then navigates to the field in
    Go. When the variant is shredded, that field is often already a typed
    column, so the full reassembly is wasted work.
    
    ### What changes are included in this PR?
    
    - Adds `compute.VariantGet(ctx, *extensions.VariantArray,
    VariantGetOptions{Path, AsType, Strict})`, mirroring the arrow-rs
    [variant_get](https://github.com/apache/arrow-rs/blob/main/parquet-variant-compute/src/variant_get.rs).
    It follows the shredded `typed_value` columns as far as the path allows
    (stepping into struct fields directly, gathering list elements with the
    `take` kernel) and reassembles the residual `value` per-row only for
    what remains.
    - `AsType == nil` returns a `VariantArray` pointing at the path; set it
    to get a typed array. Typed output is produced by grouping leaves by
    their natural Arrow type and casting each group with the existing
    `compute` cast kernels, so per-element values convert to the requested
    target independent of row order.
    - `Strict` controls cast strictness: the default allows
    overflow/truncation via the cast kernels, `Strict` makes a lossy or
    impossible cast error (arrow-go has no per-value null-on-failure mode).
    - Adds `variant.VariantPath` (an opaque
    `Field`/`Index`/`Join`/`Len`/`StepAt` builder) and
    `variant.Value.GetByPath`, plus a `VariantArray.VariantType()` accessor.
    
    ### Follow-ups (out of scope here):
    
    - Nested `AsType` output: struct/list target types return
    `arrow.ErrNotImplemented`; materializing them would need a recursive
    per-field builder.
    
    ### Notes on cross-implementation behavior
    
    Path navigation follows arrow-rs rather than Spark's `variant_get` in
    two places: a field access on a non-object errors (Spark returns null),
    and `Strict` treats in-range fractional truncation as an error (Spark
    truncates). Absent paths, out-of-range indices, and
    object/array-to-primitive casts match Spark (null, or error under
    `Strict`).
    
    ### Are these changes tested?
    
    - `arrow/compute/variant_get_test.go` covers typed and variant output,
    nested field and list-index paths, residual/mixed-shredded rows, missing
    field, null rows, dictionary-encoded metadata, mixed-width/type leaves,
    order independence, the perfect-shredding fast path, `Strict` vs default
    cast outcomes, nested-type rejection, field-on-scalar errors, and
    `CheckedAllocator` leak checks.
    - `parquet/variant/path_test.go` covers the `VariantPath` builder and
    `GetByPath` navigation, including the empty-string object key.
    
    ### Are there any user-facing changes?
    
    Yes - new exported API: `compute.VariantGet`,
    `compute.VariantGetOptions`, `variant.VariantPath`,
    `variant.Value.GetByPath`, and `extensions.VariantArray.VariantType()`.
    Additive only.
---
 arrow/compute/variant_get.go      | 750 ++++++++++++++++++++++++++++++++
 arrow/compute/variant_get_test.go | 875 ++++++++++++++++++++++++++++++++++++++
 arrow/extensions/variant.go       |  10 +
 parquet/variant/path.go           | 109 +++++
 parquet/variant/path_test.go      | 116 +++++
 5 files changed, 1860 insertions(+)

diff --git a/arrow/compute/variant_get.go b/arrow/compute/variant_get.go
new file mode 100644
index 00000000..8f5a2e2a
--- /dev/null
+++ b/arrow/compute/variant_get.go
@@ -0,0 +1,750 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compute
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/apache/arrow-go/v18/arrow"
+	"github.com/apache/arrow-go/v18/arrow/array"
+	"github.com/apache/arrow-go/v18/arrow/bitutil"
+	"github.com/apache/arrow-go/v18/arrow/decimal"
+	"github.com/apache/arrow-go/v18/arrow/decimal128"
+	"github.com/apache/arrow-go/v18/arrow/extensions"
+	"github.com/apache/arrow-go/v18/arrow/memory"
+	"github.com/apache/arrow-go/v18/parquet/variant"
+	"github.com/google/uuid"
+)
+
+// VariantGetOptions controls VariantGet.
+type VariantGetOptions struct {
+	// Path is the path to extract from every variant value.
+	Path variant.VariantPath
+	// AsType, when nil, makes VariantGet return a VariantArray pointing at the path;
+	// when set, the extracted values are cast to it via the cast kernels.
+	AsType arrow.DataType
+	// Strict makes a lossy cast fail; otherwise a value that cannot convert to AsType
+	// nulls only that row, not the rest of its natural-type group.
+	Strict bool
+}
+
+// VariantGet extracts opts.Path from every value of input. It follows the shredded
+// typed_value columns as far as the path allows - stepping into struct fields
+// directly and gathering list elements with the take kernel - then reassembles only
+// the residual for any remaining path. With AsType nil it returns a VariantArray of
+// the extracted values; otherwise it casts them to AsType with the cast kernels.
+func VariantGet(ctx context.Context, input *extensions.VariantArray, opts VariantGetOptions) (arrow.Array, error) {
+	if input == nil {
+		return nil, fmt.Errorf("%w: VariantGet requires a non-nil VariantArray", arrow.ErrInvalid)
+	}
+
+	// Reject nested target storage. Unwrap extension types first: they embed
+	// ExtensionBase and so satisfy arrow.NestedType even when backed by e.g. UUID.
+	nestedCheck := opts.AsType
+	if ext, ok := nestedCheck.(arrow.ExtensionType); ok {
+		nestedCheck = ext.StorageType()
+	}
+	if _, ok := nestedCheck.(arrow.NestedType); ok {
+		return nil, fmt.Errorf("%w: VariantGet cast to nested type %s", arrow.ErrNotImplemented, opts.AsType)
+	}
+
+	// Empty path, no cast: the values are returned unchanged.
+	if opts.Path.Len() == 0 && opts.AsType == nil {
+		input.Retain()
+
+		return input, nil
+	}
+
+	return shreddedGetPath(ctx, input, opts)
+}
+
+// shreddingState is a (value?, typed_value?) column pair at one level of a shredded
+// variant, mirroring arrow-rs ShreddingState.
+type shreddingState struct {
+	value      arrow.TypedArray[[]byte]
+	typedValue arrow.Array
+	length     int
+}
+
+func stateFromInput(input *extensions.VariantArray) shreddingState {
+	return shreddingState{
+		value:      input.UntypedValues(),
+		typedValue: input.Shredded(),
+		length:     input.Len(),
+	}
+}
+
+func stateFromFieldStruct(child *array.Struct) shreddingState {
+	ct := child.DataType().(*arrow.StructType)
+
+	var value arrow.TypedArray[[]byte]
+	if idx, ok := ct.FieldIdx("value"); ok {
+		value = child.Field(idx).(arrow.TypedArray[[]byte])
+	}
+
+	var typed arrow.Array
+	if idx, ok := ct.FieldIdx("typed_value"); ok {
+		typed = child.Field(idx)
+	}
+
+	return shreddingState{value: value, typedValue: typed, length: child.Len()}
+}
+
+type pathStepKind int
+
+const (
+	stepSuccess pathStepKind = iota
+	stepMissing
+)
+
+type pathStep struct {
+	kind  pathStepKind
+	state shreddingState
+	owned []arrow.Array // intermediate take results the caller must release
+}
+
+// missingStep marks a path step whose typed field is absent. The descent loop breaks
+// on any residual before stepping, so a step that reaches here is provably missing.
+func (s shreddingState) missingStep() pathStep {
+	return pathStep{kind: stepMissing}
+}
+
+// hasResidual reports whether any row carries a value in this level's value column.
+func (s shreddingState) hasResidual() bool {
+	return s.value != nil && s.value.NullN() != s.value.Len()
+}
+
+func fieldStep(s shreddingState, name string) (pathStep, error) {
+	if s.typedValue == nil {
+		return s.missingStep(), nil
+	}
+	st, ok := s.typedValue.(*array.Struct)
+	if !ok {
+		// A field step into a non-object shredded value is a type error, matching the
+		// per-row GetByPath path. Any residual was already diverted before this runs.
+		return pathStep{}, fmt.Errorf("%w: variant path field %q applied to non-object %s",
+			arrow.ErrInvalid, name, s.typedValue.DataType())
+	}
+	idx, ok := st.DataType().(*arrow.StructType).FieldIdx(name)
+	if !ok {
+		return s.missingStep(), nil
+	}
+	child, ok := st.Field(idx).(*array.Struct)
+	if !ok {
+		return pathStep{}, fmt.Errorf("%w: expected struct field %q while following path, got %s",
+			arrow.ErrInvalid, name, st.Field(idx).DataType())
+	}
+
+	return pathStep{kind: stepSuccess, state: stateFromFieldStruct(child)}, nil
+}
+
+// indexStep gathers element index from every row of a shredded list with the take
+// kernel, producing the shredding state one level deeper.
+func indexStep(ctx context.Context, mem memory.Allocator, s shreddingState, index int) (pathStep, error) {
+	if s.typedValue == nil {
+		return s.missingStep(), nil
+	}
+	list, ok := s.typedValue.(array.ListLike)
+	if !ok {
+		return s.missingStep(), nil
+	}
+	elems, ok := list.ListValues().(*array.Struct)
+	if !ok {
+		return s.missingStep(), nil
+	}
+
+	ib := array.NewUint64Builder(mem)
+	defer ib.Release()
+	ib.Reserve(s.length)
+	for row := 0; row < s.length; row++ {
+		start, end := list.ValueOffsets(row)
+		if list.IsValid(row) && index >= 0 && int64(index) < end-start {
+			ib.Append(uint64(start + int64(index)))
+		} else {
+			ib.AppendNull()
+		}
+	}
+	indices := ib.NewArray()
+	defer indices.Release()
+
+	et := elems.DataType().(*arrow.StructType)
+	var owned []arrow.Array
+	var next shreddingState
+	next.length = s.length
+
+	if vi, ok := et.FieldIdx("value"); ok {
+		taken, err := TakeArray(ctx, elems.Field(vi), indices)
+		if err != nil {
+			return pathStep{}, err
+		}
+		owned = append(owned, taken)
+		next.value = taken.(arrow.TypedArray[[]byte])
+	}
+	if ti, ok := et.FieldIdx("typed_value"); ok {
+		taken, err := TakeArray(ctx, elems.Field(ti), indices)
+		if err != nil {
+			releaseAll(owned)
+
+			return pathStep{}, err
+		}
+		owned = append(owned, taken)
+		next.typedValue = taken
+	}
+
+	return pathStep{kind: stepSuccess, state: next, owned: owned}, nil
+}
+
+func releaseAll(arrs []arrow.Array) {
+	for _, a := range arrs {
+		a.Release()
+	}
+}
+
+func shreddedGetPath(ctx context.Context, input *extensions.VariantArray, opts VariantGetOptions) (arrow.Array, error) {
+	mem := GetAllocator(ctx)
+	state := stateFromInput(input)
+	nulls := newNullTracker(input.Len(), mem)
+	defer nulls.release()
+	nulls.merge(input.Storage())
+
+	var owned []arrow.Array
+	defer func() { releaseAll(owned) }()
+
+	idx := 0
+	for idx < opts.Path.Len() {
+		// residual-backed rows live in the value column, unreachable by the typed_value descent; reassemble per-row.
+		if state.hasResidual() {
+			break
+		}
+
+		name, index, isField := opts.Path.StepAt(idx)
+		var (
+			step pathStep
+			err  error
+		)
+		if isField {
+			step, err = fieldStep(state, name)
+		} else {
+			step, err = indexStep(ctx, mem, state, index)
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		if step.kind == stepSuccess {
+			nulls.merge(state.typedValue)
+			state = step.state
+			owned = append(owned, step.owned...)
+			idx++
+
+			continue
+		}
+
+		// stepMissing: the typed field is provably absent (no residual, checked above).
+		return allNullResult(mem, input.Len(), opts.AsType), nil
+	}
+
+	remaining := subPath(opts.Path, idx)
+
+	// Try to return the typed column directly before building the target array,
+	// so a perfect shredding does not allocate a struct and bitmap it discards.
+	if remaining.Len() == 0 && opts.AsType != nil {
+		if col := perfectShredded(state, nulls, opts.AsType); col != nil {
+			defer col.Release()
+
+			return CastArray(ctx, col, NewCastOptions(opts.AsType, opts.Strict))
+		}
+	}
+
+	target, err := buildTargetVariant(input, state, nulls, mem)
+	if err != nil {
+		return nil, err
+	}
+	defer target.Release()
+
+	if remaining.Len() == 0 && opts.AsType == nil {
+		target.Retain()
+
+		return target, nil
+	}
+
+	leaves, err := extractLeaves(target, remaining)
+	if err != nil {
+		return nil, err
+	}
+	if opts.AsType == nil {
+		return buildLeafVariantArray(mem, leaves), nil
+	}
+
+	return castLeaves(ctx, mem, leaves, opts.AsType, opts.Strict)
+}
+
+// perfectShredded returns the typed_value column when the path landed on a fully
+// shredded value of exactly AsType and no ancestor nulls need merging; otherwise
+// the caller's reassembly path produces the same values.
+func perfectShredded(s shreddingState, nulls *nullTracker, asType arrow.DataType) arrow.Array {
+	if s.typedValue == nil || !nulls.allValid() {
+		return nil
+	}
+	if !arrow.TypeEqual(s.typedValue.DataType(), asType) {
+		return nil
+	}
+	if s.value != nil && s.value.NullN() != s.value.Len() {
+		return nil
+	}
+
+	s.typedValue.Retain()
+
+	return s.typedValue
+}
+
+func buildTargetVariant(input *extensions.VariantArray, s shreddingState, nulls *nullTracker, mem memory.Allocator) (*extensions.VariantArray, error) {
+	// Read the raw metadata column rather than input.Metadata(), which asserts plain
+	// binary and panics on dictionary-encoded metadata; the raw column preserves
+	// dictionary/large-binary encoding and is decoded by the target's own reader.
+	storage := input.Storage().(*array.Struct)
+	mdIdx, ok := storage.DataType().(*arrow.StructType).FieldIdx("metadata")
+	if !ok {
+		return nil, fmt.Errorf("%w: variant storage is missing its metadata field", arrow.ErrInvalid)
+	}
+	metadata := storage.Field(mdIdx)
+
+	fields := []arrow.Field{{Name: "metadata", Type: metadata.DataType(), Nullable: false}}
+	cols := []arrow.Array{metadata}
+	if s.value != nil {
+		fields = append(fields, arrow.Field{Name: "value", Type: s.value.DataType(), Nullable: true})
+		cols = append(cols, s.value)
+	}
+	if s.typedValue != nil {
+		fields = append(fields, arrow.Field{Name: "typed_value", Type: s.typedValue.DataType(), Nullable: true})
+		cols = append(cols, s.typedValue)
+	}
+
+	bitmap, nullCount := nulls.validityBitmap()
+	st, err := array.NewStructArrayWithFieldsAndNulls(cols, fields, bitmap, nullCount, 0)
+	if err != nil {
+		return nil, err
+	}
+	defer st.Release()
+
+	vt, err := extensions.NewVariantType(st.DataType())
+	if err != nil {
+		return nil, err
+	}
+
+	return array.NewExtensionArrayWithStorage(vt, st).(*extensions.VariantArray), nil
+}
+
+// subPath returns the suffix of p starting at from, rebuilt through the opaque API.
+func subPath(p variant.VariantPath, from int) variant.VariantPath {
+	var out variant.VariantPath
+	for i := from; i < p.Len(); i++ {
+		if name, index, isField := p.StepAt(i); isField {
+			out = out.Field(name)
+		} else {
+			out = out.Index(index)
+		}
+	}
+
+	return out
+}
+
+// variantLeaf is one row's extracted value; present is false when the path is
+// absent for that row (or the row is null).
+type variantLeaf struct {
+	value   variant.Value
+	present bool
+}
+
+func extractLeaves(target *extensions.VariantArray, path variant.VariantPath) ([]variantLeaf, error) {
+	leaves := make([]variantLeaf, target.Len())
+	for i := range leaves {
+		if target.IsNull(i) {
+			continue
+		}
+		v, err := target.Value(i)
+		if err != nil {
+			return nil, fmt.Errorf("variant: reassembling row %d: %w", i, err)
+		}
+		leaf, found, err := v.GetByPath(path)
+		if err != nil {
+			return nil, err
+		}
+		leaves[i] = variantLeaf{value: leaf, present: found}
+	}
+
+	return leaves, nil
+}
+
+func buildLeafVariantArray(mem memory.Allocator, leaves []variantLeaf) arrow.Array {
+	bldr := extensions.NewVariantBuilder(mem, extensions.NewDefaultVariantType())
+	defer bldr.Release()
+	bldr.Reserve(len(leaves))
+	for _, l := range leaves {
+		if !l.present {
+			bldr.AppendNull()
+
+			continue
+		}
+		bldr.Append(l.value)
+	}
+
+	return bldr.NewArray()
+}
+
+// castLeaves converts each leaf to asType (arrow-rs variant_get parity): leaves are
+// grouped by natural type, each group cast with the cast kernels, then scattered back
+// so the result is order-independent. Strict errors on a lossy cast, else null.
+func castLeaves(ctx context.Context, mem memory.Allocator, leaves []variantLeaf, asType arrow.DataType, strict bool) (arrow.Array, error) {
+	type leafGroup struct {
+		dt   arrow.DataType
+		rows []int
+	}
+	groups := make(map[string]*leafGroup)
+	var order []string
+	for i, l := range leaves {
+		if !l.present || l.value.Type() == variant.Null {
+			continue
+		}
+		dt := naturalArrowType(l.value)
+		if dt == nil {
+			// Object/array leaves have no primitive natural type. Under Strict this is an
+			// impossible cast (errors like any other); otherwise the row stays null.
+			if strict {
+				return nil, fmt.Errorf("%w: cannot cast non-primitive variant leaf to %s", arrow.ErrInvalid, asType)
+			}
+
+			continue
+		}
+		key := dt.String()
+		g := groups[key]
+		if g == nil {
+			g = &leafGroup{dt: dt}
+			groups[key] = g
+			order = append(order, key)
+		}
+		g.rows = append(g.rows, i)
+	}
+
+	perm := make([]uint64, len(leaves))
+	valid := make([]bool, len(leaves))
+	var casted []arrow.Array
+	defer func() { releaseAll(casted) }()
+
+	var pos uint64
+	for _, key := range order {
+		g := groups[key]
+		col := buildTypedColumn(mem, g.dt, leaves, g.rows)
+		cast, err := CastArray(ctx, col, NewCastOptions(asType, strict))
+		col.Release()
+		if err == nil {
+			casted = append(casted, cast)
+			for _, row := range g.rows {
+				perm[row] = pos
+				valid[row] = true
+				pos++
+			}
+
+			continue
+		}
+		if strict {
+			return nil, err
+		}
+		// Non-strict: retry each row alone so only the inconvertible rows null, not the whole group.
+		for _, row := range g.rows {
+			rc := buildTypedColumn(mem, g.dt, leaves, []int{row})
+			one, cerr := CastArray(ctx, rc, NewCastOptions(asType, strict))
+			rc.Release()
+			if cerr != nil {
+				continue // this row alone is inconvertible; it stays null
+			}
+			casted = append(casted, one)
+			perm[row] = pos
+			valid[row] = true
+			pos++
+		}
+	}
+
+	if len(casted) == 0 {
+		return allNullResult(mem, len(leaves), asType), nil
+	}
+
+	// One natural type covering every row already sits in original order.
+	if len(casted) == 1 && pos == uint64(len(leaves)) {
+		casted[0].Retain()
+
+		return casted[0], nil
+	}
+
+	combined, err := array.Concatenate(casted, mem)
+	if err != nil {
+		return nil, err
+	}
+	defer combined.Release()
+
+	ib := array.NewUint64Builder(mem)
+	defer ib.Release()
+	ib.AppendValues(perm, valid)
+	indices := ib.NewArray()
+	defer indices.Release()
+
+	return TakeArray(ctx, combined, indices)
+}
+
+// buildTypedColumn materializes the given leaf rows, all of natural type dt, into a
+// homogeneous Arrow array the cast kernels can consume.
+func buildTypedColumn(mem memory.Allocator, dt arrow.DataType, leaves []variantLeaf, rows []int) arrow.Array {
+	bldr := array.NewBuilder(mem, dt)
+	defer bldr.Release()
+	bldr.Reserve(len(rows))
+	for _, row := range rows {
+		if !appendNatural(bldr, leaves[row].value) {
+			bldr.AppendNull()
+		}
+	}
+
+	return bldr.NewArray()
+}
+
+// allNullResult builds the all-null output for a provably missing path.
+func allNullResult(mem memory.Allocator, n int, asType arrow.DataType) arrow.Array {
+	if asType != nil {
+		return array.MakeArrayOfNull(mem, asType, n)
+	}
+
+	// MakeArrayOfNull cannot build the variant extension type (its storage struct's
+	// metadata/value are non-nullable), so append encoded variant nulls instead.
+	bldr := extensions.NewVariantBuilder(mem, extensions.NewDefaultVariantType())
+	defer bldr.Release()
+	for range n {
+		bldr.AppendNull()
+	}
+
+	return bldr.NewArray()
+}
+
+// nullTracker accumulates ancestor validity bitmaps with a bitmap AND.
+type nullTracker struct {
+	length int
+	mem    memory.Allocator
+	buf    *memory.Buffer // validity bitmap (1 = valid); nil means all valid
+}
+
+func newNullTracker(length int, mem memory.Allocator) *nullTracker {
+	return &nullTracker{length: length, mem: mem}
+}
+
+// merge folds arr's validity into the accumulated mask. Arrow validity bits are
+// 1=valid, so accumulating ancestor nulls is a bitmap AND (a row is null in the
+// result when it is null at any level) - the validity-space equivalent of OR-ing
+// null masks.
+func (n *nullTracker) merge(arr arrow.Array) {
+	if arr == nil {
+		return
+	}
+	vb := arr.Data().Buffers()[0]
+	if vb == nil {
+		return // all valid
+	}
+	off := int64(arr.Data().Offset())
+	if n.buf == nil {
+		n.buf = bitutil.BitmapAndAlloc(n.mem, vb.Bytes(), vb.Bytes(), off, off, int64(n.length), 0)
+
+		return
+	}
+	merged := bitutil.BitmapAndAlloc(n.mem, n.buf.Bytes(), vb.Bytes(), 0, off, int64(n.length), 0)
+	n.buf.Release()
+	n.buf = merged
+}
+
+func (n *nullTracker) allValid() bool { return n.buf == nil }
+
+func (n *nullTracker) validityBitmap() (*memory.Buffer, int) {
+	if n.buf == nil {
+		return nil, 0
+	}
+
+	return n.buf, n.length - bitutil.CountSetBits(n.buf.Bytes(), 0, n.length)
+}
+
+func (n *nullTracker) release() {
+	if n.buf != nil {
+		n.buf.Release()
+		n.buf = nil
+	}
+}
+
+func naturalArrowType(v variant.Value) arrow.DataType {
+	switch v.Type() {
+	case variant.Bool:
+		return arrow.FixedWidthTypes.Boolean
+	case variant.Int8:
+		return arrow.PrimitiveTypes.Int8
+	case variant.Int16:
+		return arrow.PrimitiveTypes.Int16
+	case variant.Int32:
+		return arrow.PrimitiveTypes.Int32
+	case variant.Int64:
+		return arrow.PrimitiveTypes.Int64
+	case variant.Float:
+		return arrow.PrimitiveTypes.Float32
+	case variant.Double:
+		return arrow.PrimitiveTypes.Float64
+	case variant.String:
+		return arrow.BinaryTypes.String
+	case variant.Binary:
+		return arrow.BinaryTypes.Binary
+	case variant.Date:
+		return arrow.FixedWidthTypes.Date32
+	case variant.Time:
+		return arrow.FixedWidthTypes.Time64us
+	case variant.TimestampMicros:
+		return &arrow.TimestampType{Unit: arrow.Microsecond, TimeZone: "UTC"}
+	case variant.TimestampMicrosNTZ:
+		return &arrow.TimestampType{Unit: arrow.Microsecond}
+	case variant.TimestampNanos:
+		return &arrow.TimestampType{Unit: arrow.Nanosecond, TimeZone: "UTC"}
+	case variant.TimestampNanosNTZ:
+		return &arrow.TimestampType{Unit: arrow.Nanosecond}
+	case variant.UUID:
+		return extensions.NewUUIDType()
+	case variant.Decimal4, variant.Decimal8, variant.Decimal16:
+		return &arrow.Decimal128Type{Precision: 38, Scale: int32(decimalScale(v))}
+	}
+
+	return nil
+}
+
+// appendNatural appends v to bldr when v's value matches bldr's element type,
+// reporting whether it did; callers group leaves by natural type first, so it matches.
+func appendNatural(bldr array.Builder, v variant.Value) bool {
+	switch b := bldr.(type) {
+	case *array.BooleanBuilder:
+		if x, ok := v.Value().(bool); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Int8Builder:
+		if x, ok := v.Value().(int8); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Int16Builder:
+		if x, ok := v.Value().(int16); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Int32Builder:
+		if x, ok := v.Value().(int32); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Int64Builder:
+		if x, ok := v.Value().(int64); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Float32Builder:
+		if x, ok := v.Value().(float32); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Float64Builder:
+		if x, ok := v.Value().(float64); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.StringBuilder:
+		if x, ok := v.Value().(string); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.BinaryBuilder:
+		if x, ok := v.Value().([]byte); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Date32Builder:
+		if x, ok := v.Value().(arrow.Date32); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Time64Builder:
+		if x, ok := v.Value().(arrow.Time64); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.TimestampBuilder:
+		if x, ok := v.Value().(arrow.Timestamp); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *extensions.UUIDBuilder:
+		if x, ok := v.Value().(uuid.UUID); ok {
+			b.Append(x)
+
+			return true
+		}
+	case *array.Decimal128Builder:
+		if num, ok := decimalAsNum128(v); ok && int32(decimalScale(v)) == b.Type().(*arrow.Decimal128Type).Scale {
+			b.Append(num)
+
+			return true
+		}
+	}
+
+	return false
+}
+
+func decimalScale(v variant.Value) uint8 {
+	switch d := v.Value().(type) {
+	case variant.DecimalValue[decimal.Decimal32]:
+		return d.Scale
+	case variant.DecimalValue[decimal.Decimal64]:
+		return d.Scale
+	case variant.DecimalValue[decimal.Decimal128]:
+		return d.Scale
+	}
+
+	return 0
+}
+
+func decimalAsNum128(v variant.Value) (decimal128.Num, bool) {
+	switch d := v.Value().(type) {
+	case variant.DecimalValue[decimal.Decimal32]:
+		return decimal128.FromI64(int64(d.Value.(decimal.Decimal32))), true
+	case variant.DecimalValue[decimal.Decimal64]:
+		return decimal128.FromI64(int64(d.Value.(decimal.Decimal64))), true
+	case variant.DecimalValue[decimal.Decimal128]:
+		return d.Value.(decimal.Decimal128), true
+	}
+
+	return decimal128.Num{}, false
+}
diff --git a/arrow/compute/variant_get_test.go b/arrow/compute/variant_get_test.go
new file mode 100644
index 00000000..cf841ba2
--- /dev/null
+++ b/arrow/compute/variant_get_test.go
@@ -0,0 +1,875 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compute_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/arrow-go/v18/arrow"
+	"github.com/apache/arrow-go/v18/arrow/array"
+	"github.com/apache/arrow-go/v18/arrow/compute"
+	"github.com/apache/arrow-go/v18/arrow/compute/exec"
+	"github.com/apache/arrow-go/v18/arrow/decimal"
+	"github.com/apache/arrow-go/v18/arrow/extensions"
+	"github.com/apache/arrow-go/v18/arrow/memory"
+	"github.com/apache/arrow-go/v18/parquet/variant"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func vgVariant(t *testing.T, v any) variant.Value {
+	t.Helper()
+	var b variant.Builder
+	require.NoError(t, b.Append(v))
+	val, err := b.Build()
+	require.NoError(t, err)
+
+	return val
+}
+
+func vgNonShredded(t *testing.T, mem memory.Allocator, vals ...any) *extensions.VariantArray {
+	t.Helper()
+	bldr := extensions.NewVariantBuilder(mem, extensions.NewDefaultVariantType())
+	defer bldr.Release()
+	for _, v := range vals {
+		if v == nil {
+			bldr.AppendNull()
+
+			continue
+		}
+		bldr.Append(vgVariant(t, v))
+	}
+
+	return bldr.NewArray().(*extensions.VariantArray)
+}
+
+func vgShreddedInt(t *testing.T, mem memory.Allocator, vals ...int64) *extensions.VariantArray {
+	t.Helper()
+	vt := extensions.NewShreddedVariantType(arrow.PrimitiveTypes.Int64)
+	bldr := extensions.NewVariantBuilder(mem, vt)
+	defer bldr.Release()
+	for _, v := range vals {
+		bldr.Append(vgVariant(t, v))
+	}
+
+	return bldr.NewArray().(*extensions.VariantArray)
+}
+
+func field(name string) variant.VariantPath { return variant.VariantPath{}.Field(name) }
+
+func TestVariantGetTyped(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem,
+		map[string]any{"a": int64(1)},
+		map[string]any{"a": int64(2)},
+		nil,
+	)
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a"), AsType: arrow.PrimitiveTypes.Int64})
+	require.NoError(t, err)
+	defer out.Release()
+
+	ints := out.(*array.Int64)
+	require.Equal(t, 3, ints.Len())
+	assert.EqualValues(t, 1, ints.Value(0))
+	assert.EqualValues(t, 2, ints.Value(1))
+	assert.True(t, ints.IsNull(2))
+}
+
+func TestVariantGetVariantOutput(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, map[string]any{"a": int64(7)}, map[string]any{"b": int64(9)})
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a")})
+	require.NoError(t, err)
+	defer out.Release()
+
+	varr := out.(*extensions.VariantArray)
+	v, err := varr.Value(0)
+	require.NoError(t, err)
+	assert.EqualValues(t, 7, v.Value())
+	assert.True(t, varr.IsNull(1))
+}
+
+func TestVariantGetNestedAndIndex(t *testing.T) {
+	mem := memory.DefaultAllocator
+	nested := vgNonShredded(t, mem, map[string]any{"a": map[string]any{"b": int64(5)}})
+	defer nested.Release()
+	out, err := compute.VariantGet(context.Background(), nested, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Field("a").Field("b"), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	assert.EqualValues(t, 5, out.(*array.Int64).Value(0))
+
+	arrs := vgNonShredded(t, mem, []any{int64(10), int64(20), int64(30)})
+	defer arrs.Release()
+	got, err := compute.VariantGet(context.Background(), arrs, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Index(1), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer got.Release()
+	assert.EqualValues(t, 20, got.(*array.Int64).Value(0))
+
+	oob, err := compute.VariantGet(context.Background(), arrs, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Index(9), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer oob.Release()
+	assert.True(t, oob.(*array.Int64).IsNull(0))
+}
+
+// TestVariantGetMixedShreddedRows reproduces zeroshade's [1,2] case: row 0 is in
+// typed_value, row 1 is in the residual value. Both must come back, not [1,null].
+func TestVariantGetMixedShreddedRows(t *testing.T) {
+	mem := memory.DefaultAllocator
+	s := arrow.StructOf(
+		arrow.Field{Name: "metadata", Type: arrow.BinaryTypes.Binary},
+		arrow.Field{Name: "value", Type: arrow.BinaryTypes.Binary, Nullable: true},
+		arrow.Field{Name: "typed_value", Type: arrow.PrimitiveTypes.Int64, Nullable: true})
+	b := array.NewStructBuilder(mem, s)
+	defer b.Release()
+	mb := b.FieldBuilder(0).(*array.BinaryBuilder)
+	vb := b.FieldBuilder(1).(*array.BinaryBuilder)
+	tb := b.FieldBuilder(2).(*array.Int64Builder)
+
+	b.Append(true)
+	mb.Append(variant.EmptyMetadataBytes[:])
+	vb.AppendNull()
+	tb.Append(1)
+
+	b.Append(true)
+	mb.Append(variant.EmptyMetadataBytes[:])
+	enc, err := variant.Encode(int64(2))
+	require.NoError(t, err)
+	vb.Append(enc)
+	tb.AppendNull()
+
+	st := b.NewArray()
+	defer st.Release()
+	vt, err := extensions.NewVariantType(s)
+	require.NoError(t, err)
+	arr := array.NewExtensionArrayWithStorage(vt, st).(*extensions.VariantArray)
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{AsType: arrow.PrimitiveTypes.Int64})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	require.Equal(t, 2, ints.Len())
+	assert.EqualValues(t, 1, ints.Value(0))
+	assert.EqualValues(t, 2, ints.Value(1), "residual-value row must be reconstructed, not null")
+	assert.False(t, ints.IsNull(1))
+}
+
+// TestVariantGetLenientCast covers zeroshade's :269 examples: ordinary widening
+// casts succeed under the default (non-strict) mode.
+func TestVariantGetLenientCast(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgShreddedInt(t, mem, 3, 5)
+	defer arr.Release()
+
+	f64, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{AsType: arrow.PrimitiveTypes.Float64})
+	require.NoError(t, err)
+	defer f64.Release()
+	assert.EqualValues(t, 3, f64.(*array.Float64).Value(0))
+	assert.EqualValues(t, 5, f64.(*array.Float64).Value(1))
+
+	dec, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{AsType: &arrow.Decimal128Type{Precision: 10, Scale: 0}})
+	require.NoError(t, err)
+	defer dec.Release()
+	assert.Equal(t, 2, dec.Len())
+}
+
+func TestVariantGetStrictCastErrors(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgShreddedInt(t, mem, 5_000_000_000) // overflows int8
+	defer arr.Release()
+
+	_, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		AsType: arrow.PrimitiveTypes.Int8, Strict: true,
+	})
+	require.Error(t, err, "strict cast of an overflowing value must error")
+}
+
+// TestVariantGetFieldOnScalarErrors covers :323: a field step into a scalar is a
+// type error, not a silent null.
+func TestVariantGetFieldOnScalarErrors(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, int64(1))
+	defer arr.Release()
+
+	_, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a")})
+	require.ErrorIs(t, err, arrow.ErrInvalid)
+}
+
+// TestVariantGetHugeIndex covers :307: a huge index must not wrap to a valid one.
+func TestVariantGetHugeIndex(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, []any{int64(10), int64(20)})
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Index(1 << 40), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	assert.True(t, out.(*array.Int64).IsNull(0))
+}
+
+func TestVariantGetEmptyPath(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, int64(1), int64(2))
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{})
+	require.NoError(t, err)
+	defer out.Release()
+	assert.Equal(t, 2, out.Len())
+}
+
+// TestVariantGetShreddedFieldPushdown drives nested field steps through the shredded
+// typed_value columns and the perfect-shredding fast path.
+func TestVariantGetShreddedFieldPushdown(t *testing.T) {
+	mem := memory.DefaultAllocator
+	vt := extensions.NewShreddedVariantType(arrow.StructOf(
+		arrow.Field{Name: "a", Type: arrow.StructOf(
+			arrow.Field{Name: "b", Type: arrow.PrimitiveTypes.Int64})}))
+	bldr := extensions.NewVariantBuilder(mem, vt)
+	defer bldr.Release()
+	bldr.Append(vgVariant(t, map[string]any{"a": map[string]any{"b": int64(5)}}))
+	bldr.Append(vgVariant(t, map[string]any{"a": map[string]any{"b": int64(6)}}))
+	arr := bldr.NewArray().(*extensions.VariantArray)
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Field("a").Field("b"), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	assert.EqualValues(t, 5, ints.Value(0))
+	assert.EqualValues(t, 6, ints.Value(1))
+}
+
+// TestVariantGetShreddedListIndex drives an index step over a shredded list, which
+// gathers elements with the take kernel.
+func TestVariantGetShreddedListIndex(t *testing.T) {
+	mem := memory.DefaultAllocator
+	vt := extensions.NewShreddedVariantType(arrow.ListOf(arrow.PrimitiveTypes.Int64))
+	bldr := extensions.NewVariantBuilder(mem, vt)
+	defer bldr.Release()
+	bldr.Append(vgVariant(t, []any{int64(10), int64(20), int64(30)}))
+	bldr.Append(vgVariant(t, []any{int64(40), int64(50)}))
+	arr := bldr.NewArray().(*extensions.VariantArray)
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Index(1), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	assert.EqualValues(t, 20, ints.Value(0))
+	assert.EqualValues(t, 50, ints.Value(1))
+}
+
+func TestVariantGetNoLeak(t *testing.T) {
+	mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+	defer mem.AssertSize(t, 0)
+	ctx := exec.WithAllocator(context.Background(), mem)
+
+	vt := extensions.NewShreddedVariantType(arrow.ListOf(arrow.PrimitiveTypes.Int64))
+	bldr := extensions.NewVariantBuilder(mem, vt)
+	bldr.Append(vgVariant(t, []any{int64(10), int64(20)}))
+	bldr.AppendNull()
+	arr := bldr.NewArray().(*extensions.VariantArray)
+	bldr.Release()
+
+	idx, err := compute.VariantGet(ctx, arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Index(0), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	idx.Release()
+	arr.Release()
+
+	// A missing field on an object-shredded variant exercises the all-null path.
+	objVT := extensions.NewShreddedVariantType(arrow.StructOf(arrow.Field{Name: "a", Type: arrow.PrimitiveTypes.Int64}))
+	ob := extensions.NewVariantBuilder(mem, objVT)
+	ob.Append(vgVariant(t, map[string]any{"a": int64(1)}))
+	objArr := ob.NewArray().(*extensions.VariantArray)
+	ob.Release()
+
+	missing, err := compute.VariantGet(ctx, objArr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Field("nope"), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	missing.Release()
+	objArr.Release()
+}
+
+// TestVariantGetDictMetadata guards against a panic when the metadata column is
+// dictionary-encoded (spec-legal): buildTargetVariant must read the raw column,
+// not the plain-binary accessor.
+func TestVariantGetDictMetadata(t *testing.T) {
+	mem := memory.DefaultAllocator
+	s := arrow.StructOf(
+		arrow.Field{Name: "metadata", Type: &arrow.DictionaryType{IndexType: arrow.PrimitiveTypes.Uint8, ValueType: arrow.BinaryTypes.Binary}},
+		arrow.Field{Name: "value", Type: arrow.BinaryTypes.Binary, Nullable: true},
+		arrow.Field{Name: "typed_value", Type: arrow.StructOf(
+			arrow.Field{Name: "a", Type: arrow.StructOf(
+				arrow.Field{Name: "value", Type: arrow.BinaryTypes.Binary, Nullable: true},
+				arrow.Field{Name: "typed_value", Type: arrow.PrimitiveTypes.Int64, Nullable: true},
+			)},
+		), Nullable: true})
+	vt, err := extensions.NewVariantType(s)
+	require.NoError(t, err)
+	bldr := vt.NewBuilder(mem).(*extensions.VariantBuilder)
+	defer bldr.Release()
+	bldr.Append(vgVariant(t, map[string]any{"a": int64(5), "b": "resid"}))
+	arr := bldr.NewArray().(*extensions.VariantArray)
+	defer arr.Release()
+
+	// "b" is not shredded, so this takes the NotShredded -> buildTargetVariant path.
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: variant.VariantPath{}.Field("b")})
+	require.NoError(t, err)
+	defer out.Release()
+	v, err := out.(*extensions.VariantArray).Value(0)
+	require.NoError(t, err)
+	assert.Equal(t, "resid", v.Value())
+}
+
+// vgMixedResidual builds a two-row shredded array: row 0 shredded (fill populates typed_value, top value null),
+// row 1 residual (top typed_value null, top value = resid). Storage comes from NewShreddedVariantType.
+func vgMixedResidual(t *testing.T, mem memory.Allocator, shredType arrow.DataType, fill func(b array.Builder), resid variant.Value) *extensions.VariantArray {
+	t.Helper()
+	vt := extensions.NewShreddedVariantType(shredType)
+	s := vt.StorageType().(*arrow.StructType)
+	mIdx, _ := s.FieldIdx("metadata")
+	vIdx, _ := s.FieldIdx("value")
+	tIdx, _ := s.FieldIdx("typed_value")
+
+	b := array.NewStructBuilder(mem, s)
+	defer b.Release()
+	mb := b.FieldBuilder(mIdx).(*array.BinaryBuilder)
+	vb := b.FieldBuilder(vIdx).(*array.BinaryBuilder)
+
+	b.Append(true)
+	mb.Append(variant.EmptyMetadataBytes[:])
+	vb.AppendNull()
+	fill(b.FieldBuilder(tIdx))
+
+	b.Append(true)
+	mb.Append(resid.Metadata().Bytes())
+	vb.Append(resid.Bytes())
+	b.FieldBuilder(tIdx).AppendNull()
+
+	st := b.NewArray()
+	defer st.Release()
+
+	return array.NewExtensionArrayWithStorage(vt, st).(*extensions.VariantArray)
+}
+
+// TestVariantGetResidualBackedField covers zeroshade's blocking :233 case for a root
+// field path: row 1's {"a":2} lives in the top residual, so $.a must return 2 not null.
+func TestVariantGetResidualBackedField(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgMixedResidual(t, mem, arrow.StructOf(arrow.Field{Name: "a", Type: arrow.PrimitiveTypes.Int64}),
+		func(b array.Builder) {
+			tv := b.(*array.StructBuilder)
+			tv.Append(true)
+			a := tv.FieldBuilder(0).(*array.StructBuilder)
+			a.Append(true)
+			a.FieldBuilder(0).(*array.BinaryBuilder).AppendNull()
+			a.FieldBuilder(1).(*array.Int64Builder).Append(1)
+		}, vgVariant(t, map[string]any{"a": int64(2)}))
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a"), AsType: arrow.PrimitiveTypes.Int64})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	require.Equal(t, 2, ints.Len())
+	assert.EqualValues(t, 1, ints.Value(0))
+	assert.EqualValues(t, 2, ints.Value(1), "residual-backed row must be reassembled, not nulled")
+}
+
+// TestVariantGetResidualBackedNestedField covers the nested-field case: $.a.b where
+// row 1's whole {"a":{"b":6}} lives in the top residual.
+func TestVariantGetResidualBackedNestedField(t *testing.T) {
+	mem := memory.DefaultAllocator
+	shred := arrow.StructOf(arrow.Field{Name: "a", Type: arrow.StructOf(arrow.Field{Name: "b", Type: arrow.PrimitiveTypes.Int64})})
+	arr := vgMixedResidual(t, mem, shred,
+		func(b array.Builder) {
+			tv := b.(*array.StructBuilder)
+			tv.Append(true)
+			a := tv.FieldBuilder(0).(*array.StructBuilder)
+			a.Append(true)
+			a.FieldBuilder(0).(*array.BinaryBuilder).AppendNull()
+			aTV := a.FieldBuilder(1).(*array.StructBuilder)
+			aTV.Append(true)
+			bf := aTV.FieldBuilder(0).(*array.StructBuilder)
+			bf.Append(true)
+			bf.FieldBuilder(0).(*array.BinaryBuilder).AppendNull()
+			bf.FieldBuilder(1).(*array.Int64Builder).Append(5)
+		}, vgVariant(t, map[string]any{"a": map[string]any{"b": int64(6)}}))
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Field("a").Field("b"), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	assert.EqualValues(t, 5, ints.Value(0))
+	assert.EqualValues(t, 6, ints.Value(1), "residual-backed row must be reassembled, not nulled")
+}
+
+// TestVariantGetResidualBackedListIndex covers the list-index case: [0] where row 1's
+// whole [30,40] lives in the top residual.
+func TestVariantGetResidualBackedListIndex(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgMixedResidual(t, mem, arrow.ListOf(arrow.PrimitiveTypes.Int64),
+		func(b array.Builder) {
+			lb := b.(*array.ListBuilder)
+			lb.Append(true)
+			el := lb.ValueBuilder().(*array.StructBuilder)
+			for _, v := range []int64{10, 20} {
+				el.Append(true)
+				el.FieldBuilder(0).(*array.BinaryBuilder).AppendNull()
+				el.FieldBuilder(1).(*array.Int64Builder).Append(v)
+			}
+		}, vgVariant(t, []any{int64(30), int64(40)}))
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Index(0), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	assert.EqualValues(t, 10, ints.Value(0))
+	assert.EqualValues(t, 30, ints.Value(1), "residual-backed row must be reassembled, not nulled")
+}
+
+// TestVariantGetMixedWidthIntegers covers zeroshade :411: variant ints encode at their
+// natural width, so a wider AsType must not drop rows whose leaf shredded narrower.
+func TestVariantGetMixedWidthIntegers(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem,
+		map[string]any{"a": int64(1)},                // int8
+		map[string]any{"a": int64(1000)},             // int16
+		map[string]any{"a": int64(5_000_000_000)},    // int64
+		map[string]any{"a": int64(9007199254740993)}) // int64 > 2^53, must stay exact (no float intermediate)
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a"), AsType: arrow.PrimitiveTypes.Int64})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	require.Equal(t, 4, ints.Len())
+	assert.EqualValues(t, 1, ints.Value(0))
+	assert.EqualValues(t, 1000, ints.Value(1), "narrower-width leaf must not be dropped")
+	assert.EqualValues(t, 5_000_000_000, ints.Value(2))
+	assert.EqualValues(t, 9007199254740993, ints.Value(3), "value > 2^53 must be exact, not routed through float64")
+}
+
+// TestVariantGetHeterogeneousLeaves (arrow-rs parity): a valid int64 survives a
+// narrower-typed sibling; only a non-numeric string nulls.
+func TestVariantGetHeterogeneousLeaves(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem,
+		map[string]any{"a": int64(1)},             // int8 encoding
+		map[string]any{"a": int64(5_000_000_000)}, // int64, does not fit int8
+		map[string]any{"a": "x"})                  // non-numeric -> null
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a"), AsType: arrow.PrimitiveTypes.Int64})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	assert.EqualValues(t, 1, ints.Value(0))
+	assert.EqualValues(t, 5_000_000_000, ints.Value(1), "valid int64 must survive a narrower-typed sibling")
+	assert.True(t, ints.IsNull(2), "non-numeric string must be null")
+}
+
+// TestVariantGetEmptyKey covers zeroshade's blocking path.go:42 case: an empty-string
+// object key is a field step, not array index 0.
+func TestVariantGetEmptyKey(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, map[string]any{"": int64(42)})
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field(""), AsType: arrow.PrimitiveTypes.Int64})
+	require.NoError(t, err)
+	defer out.Release()
+	assert.EqualValues(t, 42, out.(*array.Int64).Value(0))
+}
+
+// TestVariantGetResidualBackedDeepField exercises the residual break after a successful
+// columnar descent: row 0 is shredded through a.b, row 1's {"b":6} sits in a's residual.
+func TestVariantGetResidualBackedDeepField(t *testing.T) {
+	mem := memory.DefaultAllocator
+	shred := arrow.StructOf(arrow.Field{Name: "a", Type: arrow.StructOf(arrow.Field{Name: "b", Type: arrow.PrimitiveTypes.Int64})})
+	vt := extensions.NewShreddedVariantType(shred)
+	s := vt.StorageType().(*arrow.StructType)
+	mIdx, _ := s.FieldIdx("metadata")
+	vIdx, _ := s.FieldIdx("value")
+	tIdx, _ := s.FieldIdx("typed_value")
+
+	b := array.NewStructBuilder(mem, s)
+	defer b.Release()
+	mb := b.FieldBuilder(mIdx).(*array.BinaryBuilder)
+	vb := b.FieldBuilder(vIdx).(*array.BinaryBuilder)
+	tvb := b.FieldBuilder(tIdx).(*array.StructBuilder) // struct{a}
+	aField := tvb.FieldBuilder(0).(*array.StructBuilder)
+	aVal := aField.FieldBuilder(0).(*array.BinaryBuilder)
+	aTyped := aField.FieldBuilder(1).(*array.StructBuilder) // struct{b}
+	bField := aTyped.FieldBuilder(0).(*array.StructBuilder)
+	bVal := bField.FieldBuilder(0).(*array.BinaryBuilder)
+	bTyped := bField.FieldBuilder(1).(*array.Int64Builder)
+
+	// row 0: fully shredded a.b = 5
+	b.Append(true)
+	mb.Append(variant.EmptyMetadataBytes[:])
+	vb.AppendNull()
+	tvb.Append(true)
+	aField.Append(true)
+	aVal.AppendNull()
+	aTyped.Append(true)
+	bField.Append(true)
+	bVal.AppendNull()
+	bTyped.Append(5)
+
+	// row 1: a is residual-backed with {"b":6} (top typed_value present, a.value set)
+	resid := vgVariant(t, map[string]any{"b": int64(6)})
+	b.Append(true)
+	mb.Append(resid.Metadata().Bytes())
+	vb.AppendNull()
+	tvb.Append(true)
+	aField.Append(true)
+	aVal.Append(resid.Bytes())
+	aTyped.AppendNull() // a.typed_value null -> recurses bField/bVal/bTyped null
+
+	st := b.NewArray()
+	defer st.Release()
+	arr := array.NewExtensionArrayWithStorage(vt, st).(*extensions.VariantArray)
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Field("a").Field("b"), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	ints := out.(*array.Int64)
+	require.Equal(t, 2, ints.Len())
+	assert.EqualValues(t, 5, ints.Value(0))
+	assert.EqualValues(t, 6, ints.Value(1), "mid-level residual row must be reassembled, not nulled")
+}
+
+// TestVariantGetResidualNoLeak guards the residual break path (buildTargetVariant +
+// per-row reassembly) against leaks, which TestVariantGetNoLeak does not reach.
+func TestVariantGetResidualNoLeak(t *testing.T) {
+	mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+	defer mem.AssertSize(t, 0)
+	ctx := exec.WithAllocator(context.Background(), mem)
+
+	arr := vgMixedResidual(t, mem, arrow.ListOf(arrow.PrimitiveTypes.Int64),
+		func(bld array.Builder) {
+			lb := bld.(*array.ListBuilder)
+			lb.Append(true)
+			el := lb.ValueBuilder().(*array.StructBuilder)
+			for _, v := range []int64{10, 20} {
+				el.Append(true)
+				el.FieldBuilder(0).(*array.BinaryBuilder).AppendNull()
+				el.FieldBuilder(1).(*array.Int64Builder).Append(v)
+			}
+		}, vgVariant(t, []any{int64(30), int64(40)}))
+
+	out, err := compute.VariantGet(ctx, arr, compute.VariantGetOptions{
+		Path: variant.VariantPath{}.Index(0), AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+	out.Release()
+	arr.Release()
+}
+
+// vgNonShreddedVals builds a non-shredded array from pre-built values, so a test can
+// control per-value encoding (e.g. timestamp unit) that vgNonShredded cannot.
+func vgNonShreddedVals(t *testing.T, mem memory.Allocator, vals ...variant.Value) *extensions.VariantArray {
+	t.Helper()
+	bldr := extensions.NewVariantBuilder(mem, extensions.NewDefaultVariantType())
+	defer bldr.Release()
+	for _, v := range vals {
+		bldr.Append(v)
+	}
+
+	return bldr.NewArray().(*extensions.VariantArray)
+}
+
+func vgTimestamp(t *testing.T, ts arrow.Timestamp, nano bool) variant.Value {
+	t.Helper()
+	var b variant.Builder
+	opts := []variant.AppendOpt{variant.OptTimestampUTC}
+	if nano {
+		opts = append(opts, variant.OptTimestampNano)
+	}
+	require.NoError(t, b.Append(ts, opts...))
+	val, err := b.Build()
+	require.NoError(t, err)
+
+	return val
+}
+
+// TestVariantGetMixedFloatWidths (:411, floats): a Float(32) and a Double(64) leaf
+// both survive a Float64 request, independent of order.
+func TestVariantGetMixedFloatWidths(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, float32(1.5), float64(2.5))
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{AsType: arrow.PrimitiveTypes.Float64})
+	require.NoError(t, err)
+	defer out.Release()
+	f := out.(*array.Float64)
+	require.Equal(t, 2, f.Len())
+	assert.InDelta(t, 1.5, f.Value(0), 1e-9)
+	assert.InDelta(t, 2.5, f.Value(1), 1e-9, "Double leaf must not be dropped by a Float first leaf")
+}
+
+// TestVariantGetIntPlusFloat: a mixed int/float column cast to Float64 widens the int
+// through the cast kernels rather than nulling it.
+func TestVariantGetIntPlusFloat(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, int64(3), float64(2.5))
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{AsType: arrow.PrimitiveTypes.Float64})
+	require.NoError(t, err)
+	defer out.Release()
+	f := out.(*array.Float64)
+	assert.InDelta(t, 3.0, f.Value(0), 1e-9)
+	assert.InDelta(t, 2.5, f.Value(1), 1e-9)
+}
+
+// TestVariantGetTypeOrderIndependent (:411): the same two values give the same result
+// regardless of which row comes first.
+func TestVariantGetTypeOrderIndependent(t *testing.T) {
+	mem := memory.DefaultAllocator
+	forward := vgNonShredded(t, mem, int64(3), float64(2.5))
+	defer forward.Release()
+	reverse := vgNonShredded(t, mem, float64(2.5), int64(3))
+	defer reverse.Release()
+
+	optsF := compute.VariantGetOptions{AsType: arrow.PrimitiveTypes.Float64}
+	fwd, err := compute.VariantGet(context.Background(), forward, optsF)
+	require.NoError(t, err)
+	defer fwd.Release()
+	rev, err := compute.VariantGet(context.Background(), reverse, optsF)
+	require.NoError(t, err)
+	defer rev.Release()
+
+	fa, ra := fwd.(*array.Float64), rev.(*array.Float64)
+	assert.InDelta(t, fa.Value(0), ra.Value(1), 1e-9)
+	assert.InDelta(t, fa.Value(1), ra.Value(0), 1e-9)
+	assert.False(t, fa.IsNull(0) || fa.IsNull(1) || ra.IsNull(0) || ra.IsNull(1), "no leaf dropped in either order")
+}
+
+// TestVariantGetMixedTimestampUnits: a micros leaf and a nanos leaf of the same instant
+// both land on it when cast to a nanos target (units are converted, not reinterpreted).
+func TestVariantGetMixedTimestampUnits(t *testing.T) {
+	mem := memory.DefaultAllocator
+	const micros = arrow.Timestamp(1_600_000_000_000_000)
+	const nanos = arrow.Timestamp(1_600_000_000_000_000_000)
+	arr := vgNonShreddedVals(t, mem, vgTimestamp(t, micros, false), vgTimestamp(t, nanos, true))
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		AsType: &arrow.TimestampType{Unit: arrow.Nanosecond, TimeZone: "UTC"},
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	ts := out.(*array.Timestamp)
+	require.Equal(t, 2, ts.Len())
+	assert.EqualValues(t, nanos, ts.Value(0), "micros leaf must be scaled to nanos, not copied raw")
+	assert.EqualValues(t, nanos, ts.Value(1))
+}
+
+// TestVariantGetMixedDecimalScales: leaves shredded at different scales both rescale to
+// the requested target scale.
+func TestVariantGetMixedDecimalScales(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShreddedVals(t, mem,
+		vgVariant(t, variant.DecimalValue[decimal.Decimal32]{Scale: 1, Value: decimal.Decimal32(15)}),  // 1.5
+		vgVariant(t, variant.DecimalValue[decimal.Decimal32]{Scale: 2, Value: decimal.Decimal32(225)})) // 2.25
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		AsType: &arrow.Decimal128Type{Precision: 38, Scale: 2},
+	})
+	require.NoError(t, err)
+	defer out.Release()
+	d := out.(*array.Decimal128)
+	require.Equal(t, 2, d.Len())
+	assert.InDelta(t, 1.5, d.Value(0).ToFloat64(2), 1e-9, "scale-1 leaf must rescale to scale-2, not drop")
+	assert.InDelta(t, 2.25, d.Value(1).ToFloat64(2), 1e-9)
+}
+
+// TestVariantGetStrictSlowPathErrors (:411/:269): on the reassembly path a lossy cast
+// errors under Strict rather than nulling.
+func TestVariantGetStrictSlowPathErrors(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, map[string]any{"a": int64(5_000_000_000)}) // overflows int32
+	defer arr.Release()
+
+	_, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: field("a"), AsType: arrow.PrimitiveTypes.Int32, Strict: true,
+	})
+	require.Error(t, err, "Strict must error on an overflowing cast, not null it")
+}
+
+// TestVariantGetMixedTypeNoLeak guards the multi-group scatter path (cast + Concatenate
+// + Take), which the single-type leak tests do not reach.
+// TestVariantGetNestedTypeNotImplemented pins that a nested AsType is rejected with
+// ErrNotImplemented rather than silently producing an all-null array.
+func TestVariantGetNestedTypeNotImplemented(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, map[string]any{"a": map[string]any{"x": int64(1)}})
+	defer arr.Release()
+
+	for _, nested := range []arrow.DataType{
+		arrow.StructOf(arrow.Field{Name: "x", Type: arrow.PrimitiveTypes.Int64}),
+		arrow.ListOf(arrow.PrimitiveTypes.Int64),
+	} {
+		out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a"), AsType: nested})
+		if out != nil {
+			out.Release()
+		}
+		require.ErrorIs(t, err, arrow.ErrNotImplemented, "nested AsType %s must error, not null", nested)
+	}
+}
+
+func TestVariantGetMixedTypeNoLeak(t *testing.T) {
+	mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+	defer mem.AssertSize(t, 0)
+	ctx := exec.WithAllocator(context.Background(), mem)
+
+	arr := vgNonShredded(t, mem, int64(3), float64(2.5), nil, "x")
+	out, err := compute.VariantGet(ctx, arr, compute.VariantGetOptions{AsType: arrow.PrimitiveTypes.Float64})
+	require.NoError(t, err)
+	out.Release()
+	arr.Release()
+}
+
+// TestVariantGetInterleavedScatter exercises the multi-group scatter (Concatenate +
+// TakeArray) with a NON-IDENTITY permutation: two same-typed leaves straddle a
+// differently-typed one, so group order [3,7,2.5] must scatter back to row order
+// [3,2.5,7]. Every other multi-group test lands in identity order.
+func TestVariantGetInterleavedScatter(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgNonShredded(t, mem, int64(3), float64(2.5), int64(7)) // Int8{0,2}, Float64{1}
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{AsType: arrow.PrimitiveTypes.Float64})
+	require.NoError(t, err)
+	defer out.Release()
+	f := out.(*array.Float64)
+	require.Equal(t, 3, f.Len())
+	assert.InDelta(t, 3.0, f.Value(0), 1e-9)
+	assert.InDelta(t, 2.5, f.Value(1), 1e-9, "interleaved leaf must scatter back to its row, not stay in group order")
+	assert.InDelta(t, 7.0, f.Value(2), 1e-9)
+}
+
+// TestVariantGetStrictObjectLeafErrors pins that under Strict an object/array leaf cast
+// to a primitive errors (impossible cast) rather than silently nulling.
+func TestVariantGetStrictObjectLeafErrors(t *testing.T) {
+	mem := memory.DefaultAllocator
+	for _, v := range []any{
+		map[string]any{"a": map[string]any{"x": int64(1)}}, // object leaf at $.a
+		map[string]any{"a": []any{int64(1), int64(2)}},     // array leaf at $.a
+	} {
+		arr := vgNonShredded(t, mem, v)
+		out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+			Path: field("a"), AsType: arrow.PrimitiveTypes.Int64, Strict: true,
+		})
+		if out != nil {
+			out.Release()
+		}
+		arr.Release()
+		require.ErrorIs(t, err, arrow.ErrInvalid, "strict cast of a non-primitive leaf to Int64 must error")
+	}
+}
+
+// TestVariantGetShreddedFieldOnScalarErrors pins that a field step into a shredded
+// scalar column errors on the columnar path, matching the per-row GetByPath path.
+func TestVariantGetShreddedFieldOnScalarErrors(t *testing.T) {
+	mem := memory.DefaultAllocator
+	arr := vgShreddedInt(t, mem, 1, 2, 3) // typed_value is a scalar Int64 column
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{Path: field("a"), AsType: arrow.PrimitiveTypes.Int64})
+	if out != nil {
+		out.Release()
+	}
+	require.ErrorIs(t, err, arrow.ErrInvalid, "field access on a shredded scalar must error, not return null")
+}
+
+// TestVariantGetUUIDTarget pins that a UUID AsType reaches the UUID cast, not the nested-type reject.
+func TestVariantGetUUIDTarget(t *testing.T) {
+	mem := memory.DefaultAllocator
+	u := uuid.MustParse("00112233-4455-6677-8899-aabbccddeeff")
+	arr := vgNonShredded(t, mem, map[string]any{"id": u})
+	defer arr.Release()
+
+	out, err := compute.VariantGet(context.Background(), arr, compute.VariantGetOptions{
+		Path: field("id"), AsType: extensions.NewUUIDType(),
+	})
+	require.NoError(t, err, "UUID target must not be rejected as a nested type")
+	defer out.Release()
+
+	uarr, ok := out.(*extensions.UUIDArray)
+	require.True(t, ok, "expected *extensions.UUIDArray, got %T", out)
+	require.Equal(t, 1, uarr.Len())
+	require.False(t, uarr.IsNull(0))
+	assert.Equal(t, u, uarr.Value(0))
+}
+
+// TestVariantGetPartialCastFailure pins that non-strict nulls only the inconvertible row: ["1","bad"]->int64 is [1,null].
+func TestVariantGetPartialCastFailure(t *testing.T) {
+	mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+	defer mem.AssertSize(t, 0)
+	ctx := exec.WithAllocator(context.Background(), mem)
+	arr := vgNonShredded(t, mem, "1", "bad")
+
+	out, err := compute.VariantGet(ctx, arr, compute.VariantGetOptions{
+		AsType: arrow.PrimitiveTypes.Int64,
+	})
+	require.NoError(t, err)
+
+	ints := out.(*array.Int64)
+	require.Equal(t, 2, ints.Len())
+	assert.False(t, ints.IsNull(0), "valid row must survive a sibling row's cast failure")
+	assert.EqualValues(t, 1, ints.Value(0))
+	assert.True(t, ints.IsNull(1), "only the inconvertible row is null")
+
+	out.Release()
+	arr.Release()
+}
diff --git a/arrow/extensions/variant.go b/arrow/extensions/variant.go
index fee2e046..a0cc74ed 100644
--- a/arrow/extensions/variant.go
+++ b/arrow/extensions/variant.go
@@ -458,6 +458,11 @@ func (v *VariantArray) IsShredded() bool {
 	return v.ExtensionType().(*VariantType).typedValueFieldIdx != -1
 }
 
+// VariantType returns the array's extension type without the ExtensionType cast.
+func (v *VariantArray) VariantType() *VariantType {
+	return v.ExtensionType().(*VariantType)
+}
+
 // UnshredVariant returns an equivalent VariantArray in the non-shredded layout
 // (a struct of metadata and value), reassembling each row's value from the
 // shredded typed_value and value columns. If the array is already non-shredded
@@ -513,6 +518,11 @@ func (v *VariantArray) IsNull(i int) bool {
 		}
 	}
 
+	if vt.valueFieldIdx == -1 {
+		// No residual value column: a null typed_value means the value is missing.
+		return true
+	}
+
 	valArr := v.Storage().(*array.Struct).Field(vt.valueFieldIdx)
 	b := valArr.(arrow.TypedArray[[]byte]).Value(i)
 	return len(b) == 1 && b[0] == 0 // variant null
diff --git a/parquet/variant/path.go b/parquet/variant/path.go
new file mode 100644
index 00000000..bc46da77
--- /dev/null
+++ b/parquet/variant/path.go
@@ -0,0 +1,109 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package variant
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/apache/arrow-go/v18/arrow"
+)
+
+// pathElem is one step of a VariantPath: an object field when isField is set, else an array index.
+type pathElem struct {
+	name    string
+	index   int
+	isField bool
+}
+
+// VariantPath is an ordered list of steps to navigate into a variant value. The
+// zero value is the root path; extend it with Field and Index.
+type VariantPath struct {
+	elems []pathElem
+}
+
+// Field returns a copy of the path with an object-field step appended.
+func (p VariantPath) Field(name string) VariantPath {
+	return VariantPath{elems: append(p.grow(), pathElem{name: name, isField: true})}
+}
+
+// Index returns a copy of the path with an array-index step appended.
+func (p VariantPath) Index(i int) VariantPath {
+	return VariantPath{elems: append(p.grow(), pathElem{index: i})}
+}
+
+// Join returns a copy of the path with other's steps appended.
+func (p VariantPath) Join(other VariantPath) VariantPath {
+	return VariantPath{elems: append(p.grow(), other.elems...)}
+}
+
+func (p VariantPath) grow() []pathElem {
+	return append(make([]pathElem, 0, len(p.elems)+1), p.elems...)
+}
+
+// Len returns the number of steps in the path.
+func (p VariantPath) Len() int { return len(p.elems) }
+
+// StepAt returns the i-th step's name and index, with isField true for an object-field step.
+func (p VariantPath) StepAt(i int) (name string, index int, isField bool) {
+	e := p.elems[i]
+
+	return e.name, e.index, e.isField
+}
+
+// GetByPath navigates path into v and returns the leaf value. found is false when
+// the path is cleanly absent (a missing object field, or an out-of-range or
+// non-array index). It returns an error for a type error (a field step into a
+// non-object) or corrupt data (a field id not present in the metadata).
+func (v Value) GetByPath(path VariantPath) (leaf Value, found bool, err error) {
+	cur := v
+	for _, e := range path.elems {
+		if e.isField {
+			obj, ok := cur.Value().(ObjectValue)
+			if !ok {
+				return Value{}, false, fmt.Errorf("%w: variant path field %q applied to non-object", arrow.ErrInvalid, e.name)
+			}
+			field, ferr := obj.ValueByKey(e.name)
+			if ferr != nil {
+				if errors.Is(ferr, arrow.ErrNotFound) {
+					return Value{}, false, nil
+				}
+
+				return Value{}, false, ferr
+			}
+			cur = field.Value
+
+			continue
+		}
+
+		arr, ok := cur.Value().(ArrayValue)
+		if !ok {
+			return Value{}, false, nil
+		}
+		if e.index < 0 || uint64(e.index) >= uint64(arr.Len()) {
+			return Value{}, false, nil
+		}
+		el, aerr := arr.Value(uint32(e.index))
+		if aerr != nil {
+			return Value{}, false, nil
+		}
+		cur = el
+	}
+
+	return cur, true, nil
+}
diff --git a/parquet/variant/path_test.go b/parquet/variant/path_test.go
new file mode 100644
index 00000000..9de8b84a
--- /dev/null
+++ b/parquet/variant/path_test.go
@@ -0,0 +1,116 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package variant_test
+
+import (
+	"testing"
+
+	"github.com/apache/arrow-go/v18/arrow"
+	"github.com/apache/arrow-go/v18/parquet/variant"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func buildVar(t *testing.T, v any) variant.Value {
+	t.Helper()
+	var b variant.Builder
+	require.NoError(t, b.Append(v))
+	val, err := b.Build()
+	require.NoError(t, err)
+
+	return val
+}
+
+func TestGetByPathFieldAndIndex(t *testing.T) {
+	v := buildVar(t, map[string]any{"a": map[string]any{"b": int64(5)}, "arr": []any{int64(10), int64(20)}})
+
+	leaf, found, err := v.GetByPath(variant.VariantPath{}.Field("a").Field("b"))
+	require.NoError(t, err)
+	require.True(t, found)
+	assert.EqualValues(t, 5, leaf.Value())
+
+	leaf, found, err = v.GetByPath(variant.VariantPath{}.Field("arr").Index(1))
+	require.NoError(t, err)
+	require.True(t, found)
+	assert.EqualValues(t, 20, leaf.Value())
+}
+
+func TestGetByPathAbsent(t *testing.T) {
+	v := buildVar(t, map[string]any{"a": int64(1), "arr": []any{int64(10)}})
+
+	for _, p := range []variant.VariantPath{
+		variant.VariantPath{}.Field("missing"),      // absent object field
+		variant.VariantPath{}.Field("arr").Index(9), // out-of-range index
+		variant.VariantPath{}.Field("a").Index(0),   // index into a scalar
+		variant.VariantPath{}.Index(0),              // index into an object
+	} {
+		_, found, err := v.GetByPath(p)
+		require.NoError(t, err)
+		assert.False(t, found)
+	}
+}
+
+// TestGetByPathFieldOnScalarErrors: a field step into a non-object is a type error.
+func TestGetByPathFieldOnScalarErrors(t *testing.T) {
+	v := buildVar(t, int64(1))
+	_, _, err := v.GetByPath(variant.VariantPath{}.Field("a"))
+	require.ErrorIs(t, err, arrow.ErrInvalid)
+}
+
+// TestGetByPathHugeIndex: a huge index must not wrap; it is simply absent.
+func TestGetByPathHugeIndex(t *testing.T) {
+	v := buildVar(t, []any{int64(10), int64(20)})
+	_, found, err := v.GetByPath(variant.VariantPath{}.Index(1 << 40))
+	require.NoError(t, err)
+	assert.False(t, found)
+}
+
+func TestVariantPathJoinAndStepAt(t *testing.T) {
+	p := variant.VariantPath{}.Field("a").Join(variant.VariantPath{}.Index(2).Field("b"))
+	require.Equal(t, 3, p.Len())
+
+	name, _, isField := p.StepAt(0)
+	assert.Equal(t, "a", name)
+	assert.True(t, isField)
+	name, idx, isField := p.StepAt(1)
+	assert.Equal(t, "", name)
+	assert.Equal(t, 2, idx)
+	assert.False(t, isField)
+	name, _, isField = p.StepAt(2)
+	assert.Equal(t, "b", name)
+	assert.True(t, isField)
+}
+
+// TestGetByPathEmptyKey covers the empty-string object key: Field("") is a field
+// step distinct from Index(0), so it must resolve the "" key rather than index 0.
+func TestGetByPathEmptyKey(t *testing.T) {
+	v := buildVar(t, map[string]any{"": int64(42)})
+
+	_, _, isField := variant.VariantPath{}.Field("").StepAt(0)
+	assert.True(t, isField, `Field("") must be a field step, not an index step`)
+
+	leaf, found, err := v.GetByPath(variant.VariantPath{}.Field(""))
+	require.NoError(t, err)
+	require.True(t, found)
+	assert.EqualValues(t, 42, leaf.Value())
+
+	// Index(0) on the object must not match the "" key.
+	_, found, err = v.GetByPath(variant.VariantPath{}.Index(0))
+	require.NoError(t, err)
+	assert.False(t, found)
+}