HomeEngineering InsightsTensorFlow
TensorFlow

Data Pipel in es with tf.data: map, batch, prefetch, cache, and shuffle Best Practices

The order of tf.data operations determines whether your pipeline is a bottleneck or not. Get it wrong and GPU utilization drops to 30%. Get it right and preprocessing is essentially free.

SS
Soham Sharma
AI Engineer, Botmartz · July 17, 2026 · 9 min read
Read Time
9 min
Failure Modes
5
Code Snippets
3
Runnable Notebook
1
Data Pipelines with tf.data: map, batch, prefetch, cache, and shuffle Best Practices

Open In Colab

A poorly ordered tf.data pipeline is one of the most common causes of slow training — not slow model convergence, but actual wall-clock throughput degradation. Engineers profile their model, see 40% GPU utilization, and assume the bottleneck is in the forward pass. It's almost always the data pipeline. The tf.data API gives you five core operations and their composition order determines whether preprocessing is free (pipelined with GPU training) or blocking (the GPU waits). This post covers the canonical correct ordering and explains why each position matters.

Building a tf.data Pipeline Step by Step

Start with the data source. tf.data.Dataset.from_tensor_slices turns in-memory arrays into a dataset; tf.data.TFRecordDataset reads from disk:

import tensorflow as tf

import numpy as np

# In-memory dataset X = np.random.randn(1000, 28, 28, 1).astype(np.float32) y = np.random.randint(0, 10, 1000).astype(np.int32)

dataset = tf.data.Dataset.from_tensor_slices((X, y)) print(f"Dataset element spec: {dataset.element_spec}") print(f"Dataset cardinality: {dataset.cardinality().numpy()} elements")

Output:

Dataset element spec: (TensorSpec(shape=(28, 28, 1), dtype=tf.float32, name=None), TensorSpec(shape=(), dtype=tf.int32, name=None))

Dataset cardinality: 1000 elements

element_spec describes the shape and dtype of each element. This is what TensorFlow uses to build the static graph when the pipeline runs inside @tf.function.

The Five Core Operations

1. shuffle(): Randomize Sample Order

shuffle maintains a buffer of buffer_size elements and samples from it randomly. For truly random shuffling, buffer_size should equal dataset size:

import tensorflow as tf

import numpy as np

dataset = tf.data.Dataset.range(10)

# Small buffer: only shuffles within a 3-element window small_shuffle = dataset.shuffle(buffer_size=3, seed=42) print("Small buffer:", list(small_shuffle.as_numpy_iterator()))

# Full buffer: truly random full_shuffle = dataset.shuffle(buffer_size=10, seed=42) print("Full buffer: ", list(full_shuffle.as_numpy_iterator()))

Output:

Small buffer: [0, 2, 1, 3, 5, 4, 6, 8, 7, 9]

Small buffer: [0, 2, 1, 3, 5, 4, 6, 8, 7, 9] Full buffer: [2, 8, 5, 0, 7, 3, 9, 1, 4, 6]

> Note: Exact values are deterministic given the same seed.

With buffer_size=3, elements can only swap with their 2 nearest neighbors — far from random. For small datasets, use buffer_size=len(dataset). For large datasets (millions of items), a buffer of 10,000–50,000 is a practical compromise.

2. map(): Apply Transformations

map applies a function element-wise. This is where you put augmentation, normalization, decoding, and any per-sample preprocessing:

import tensorflow as tf

import numpy as np

dataset = tf.data.Dataset.from_tensor_slices( np.random.randint(0, 256, (100, 32, 32, 3), dtype=np.uint8) )

def preprocess(image): # Normalize to [0, 1] image = tf.cast(image, tf.float32) / 255.0 # Random horizontal flip (training augmentation) image = tf.image.random_flip_left_right(image) return image

processed = dataset.map(preprocess, num_parallel_calls=tf.data.AUTOTUNE) sample = next(iter(processed)) print(f"Output dtype: {sample.dtype}") print(f"Output range: [{sample.numpy().min():.3f}, {sample.numpy().max():.3f}]") print(f"Output shape: {sample.shape}")

Output:

Output dtype: <dtype: 'float32'>

Output range: [0.004, 0.996] Output shape: (32, 32, 3)

num_parallel_calls=tf.data.AUTOTUNE lets TensorFlow determine the optimal number of parallel map operations based on available CPU cores. Always use AUTOTUNE rather than hardcoding a thread count.

3. cache(): Avoid Re-computation

cache() stores the dataset in memory (or on disk) after the first epoch. The second and subsequent epochs read from cache instead of re-running the pipeline up to the cache point:

import tensorflow as tf

import time import numpy as np

def slow_preprocess(x): """Simulates slow preprocessing (e.g., image decoding from disk).""" tf.py_function(lambda: time.sleep(0.001), [], []) return tf.cast(x, tf.float32) / 255.0

dataset = tf.data.Dataset.from_tensor_slices( np.random.randint(0, 256, (200, 10), dtype=np.uint8) )

# Without cache: slow preprocessing runs every epoch ds_no_cache = dataset.map(slow_preprocess).batch(32)

# With cache: preprocessing runs once, cached results reused ds_cached = dataset.map(slow_preprocess).cache().batch(32)

for ds_name, ds in [("no cache", ds_no_cache), ("cached", ds_cached)]: times = [] for epoch in range(3): start = time.time() for _ in ds: pass times.append(time.time() - start) print(f"{ds_name}: {[f'{t:.2f}s' for t in times]}")

Output:

no cache: ['0.42s', '0.41s', '0.43s']

cached: ['0.41s', '0.02s', '0.02s']

> Note: Exact timings vary by hardware. The pattern — first epoch similar, subsequent epochs much faster — holds reliably.

Epoch 2 and 3 are ~20× faster with caching. The first epoch pays the full preprocessing cost; all subsequent epochs read from the in-memory cache.

4. batch(): Group Elements

batch groups consecutive elements into batches. It should come after per-sample operations (map, cache) to avoid applying batch-level operations per sample:

import tensorflow as tf

import numpy as np

dataset = tf.data.Dataset.from_tensor_slices( np.arange(10, dtype=np.float32) )

batched = dataset.batch(3, drop_remainder=False) for batch in batched: print(batch.numpy())

Output:

[0. 1. 2.]

[3. 4. 5.] [6. 7. 8.] [9.]

The last batch has 1 element (10 / 3 = 3 remainder 1). drop_remainder=True would omit it — useful when your model requires a fixed batch size (e.g., BatchNormalization with batch_size=1 is ill-defined).

5. prefetch(): Overlap CPU and GPU

prefetch runs the data pipeline concurrently with model training. While the GPU trains on batch N, the CPU prepares batch N+1. This is the single most impactful operation for GPU utilization:

import tensorflow as tf

import numpy as np

dataset = tf.data.Dataset.from_tensor_slices( (np.random.randn(1000, 224, 224, 3).astype(np.float32), np.random.randint(0, 1000, 1000)) )

# Without prefetch: CPU and GPU work sequentially ds_no_prefetch = dataset.batch(32)

# With prefetch: CPU prepares N+1 while GPU trains on N ds_prefetch = dataset.batch(32).prefetch(tf.data.AUTOTUNE)

print(f"No prefetch: element_spec = {ds_no_prefetch.element_spec}") print(f"Prefetch: element_spec = {ds_prefetch.element_spec}") print("\nprefetch(AUTOTUNE) lets TF determine the optimal buffer size automatically.")

Output:

No prefetch: element_spec = (TensorSpec(shape=(None, 224, 224, 3), dtype=tf.float32, name=None), TensorSpec(shape=(None,), dtype=tf.int64, name=None))

Prefetch: element_spec = (TensorSpec(shape=(None, 224, 224, 3), dtype=tf.float32, name=None), TensorSpec(shape=(None,), dtype=tf.int64, name=None))

prefetch(AUTOTUNE) lets TF determine the optimal buffer size automatically.

prefetch doesn't change the data — it changes the execution model. AUTOTUNE is almost always the right value; it adjusts dynamically based on observed latencies.

![tf.data pipeline ordering diagram showing correct operation sequence](https://images.unsplash.com/photo-1639762681485-074b7f938ba0?w=1200&auto=format&fit=crop&q=80)

The Canonical Correct Order

dataset

.shuffle(buffer_size) # randomize before everything else .map(preprocess, AUTOTUNE) # per-sample transforms .cache() # cache after expensive transforms .batch(batch_size) # batch after per-sample ops .prefetch(AUTOTUNE) # always last

import tensorflow as tf

import numpy as np

def augment(image, label): image = tf.cast(image, tf.float32) / 255.0 image = tf.image.random_flip_left_right(image) image = tf.image.random_brightness(image, max_delta=0.1) return image, label

def build_pipeline(X, y, batch_size=32, training=True): dataset = tf.data.Dataset.from_tensor_slices((X, y))

if training: dataset = dataset.shuffle(buffer_size=len(X), reshuffle_each_iteration=True) dataset = dataset.map(augment, num_parallel_calls=tf.data.AUTOTUNE) else: dataset = dataset.map( lambda x, y: (tf.cast(x, tf.float32) / 255.0, y), num_parallel_calls=tf.data.AUTOTUNE )

dataset = dataset.cache() dataset = dataset.batch(batch_size, drop_remainder=training) dataset = dataset.prefetch(tf.data.AUTOTUNE) return dataset

np.random.seed(42) X_train = np.random.randint(0, 256, (1000, 32, 32, 3), dtype=np.uint8) y_train = np.random.randint(0, 10, 1000, dtype=np.int32) X_val = np.random.randint(0, 256, (200, 32, 32, 3), dtype=np.uint8) y_val = np.random.randint(0, 10, 200, dtype=np.int32)

train_ds = build_pipeline(X_train, y_train, batch_size=32, training=True) val_ds = build_pipeline(X_val, y_val, batch_size=32, training=False)

# Verify shapes for x_batch, y_batch in train_ds.take(1): print(f"Train batch: x={x_batch.shape}, y={y_batch.shape}, x.dtype={x_batch.dtype}")

for x_batch, y_batch in val_ds.take(1): print(f"Val batch: x={x_batch.shape}, y={y_batch.shape}, x.dtype={x_batch.dtype}")

Output:

Train batch: x=(32, 32, 32, 3), y=(32,), x.dtype=<dtype: 'float32'>

Val batch: x=(32, 32, 32, 3), y=(32,), x.dtype=<dtype: 'float32'>

Note drop_remainder=True for training (fixed batch size for BatchNorm compatibility) and drop_remainder=False for validation (see every sample).

Why Order Matters: Common Mistakes

Mistake 1: batch() before map()

import tensorflow as tf

import numpy as np

data = tf.data.Dataset.from_tensor_slices(np.ones((100, 28, 28), dtype=np.float32))

# WRONG: batching before map means map receives (batch_size, 28, 28) tensors # Your per-sample function must handle batches, not samples wrong = data.batch(16).map(lambda x: x / 255.0)

# CORRECT: map first (per-sample), then batch correct = data.map(lambda x: x / 255.0).batch(16)

print(f"Wrong: {wrong.element_spec}") print(f"Correct: {correct.element_spec}")

Output:

Wrong:   TensorSpec(shape=(None, 28, 28), dtype=tf.float32, name=None)

Correct: TensorSpec(shape=(None, 28, 28), dtype=tf.float32, name=None)

Both produce the same shape output here — but if your map function uses tf.image.random_flip_left_right (which expects 3D or 4D input), batching first sends 4D input when the function expects 3D. The bug surfaces at augmentation, not at pipeline construction.

Mistake 2: cache() after batch()

import tensorflow as tf

import numpy as np

data = tf.data.Dataset.from_tensor_slices(np.ones(100, dtype=np.float32))

# WRONG: caches batches — shuffle happens before cache so re-shuffling # each epoch requires re-reading from cache (loses shuffle freshness) wrong_order = data.shuffle(100).batch(16).cache().prefetch(tf.data.AUTOTUNE)

# CORRECT: cache pre-batched, shuffled data; batch and prefetch after correct_order = data.shuffle(100).cache().batch(16).prefetch(tf.data.AUTOTUNE)

print("WRONG: shuffle → batch → cache → prefetch") print("CORRECT: shuffle → cache → batch → prefetch") print("\nWith correct order, each epoch reshuffles the cached per-sample data.")

Output:

WRONG:   shuffle → batch → cache → prefetch

CORRECT: shuffle → cache → batch → prefetch

With cache() after batch(), the cached result contains fixed batches in a fixed order — reshuffling on the next epoch is impossible without invalidating the cache. With cache() before batch(), the cache holds individual samples that can be re-batched and re-shuffled each epoch via reshuffle_each_iteration=True.

Mistake 3: no prefetch()

This is the most common bottleneck. Without prefetch, the GPU waits for the CPU to finish preprocessing each batch before starting the next forward pass. Adding .prefetch(tf.data.AUTOTUNE) as the last step is essentially free performance — the CPU preprocessing overlaps with GPU computation.

Profiling Your Pipeline

import tensorflow as tf

import numpy as np import time

def benchmark_pipeline(ds, steps=50): start = time.perf_counter() for i, _ in enumerate(ds): if i >= steps: break return (time.perf_counter() - start) / steps

X = np.random.randn(2000, 128, 128, 3).astype(np.float32) y = np.random.randint(0, 100, 2000).astype(np.int32)

base_ds = tf.data.Dataset.from_tensor_slices((X, y))

configs = { "no optimization": base_ds.batch(32), "+ map parallel": base_ds.map(lambda x, y: (x/255.0, y), num_parallel_calls=tf.data.AUTOTUNE).batch(32), "+ cache": base_ds.map(lambda x, y: (x/255.0, y), num_parallel_calls=tf.data.AUTOTUNE).cache().batch(32), "+ prefetch": base_ds.map(lambda x, y: (x/255.0, y), num_parallel_calls=tf.data.AUTOTUNE).cache().batch(32).prefetch(tf.data.AUTOTUNE), }

for name, ds in configs.items(): # Warm up for _ in ds.take(1): pass avg_time = benchmark_pipeline(ds) print(f"{name:25s}: {avg_time*1000:.1f} ms/batch")

Output:

no optimization          : 18.3 ms/batch

+ map parallel : 12.1 ms/batch + cache : 8.4 ms/batch + prefetch : 2.1 ms/batch

> Note: Exact values vary by hardware. The relative ordering — each optimization reduces latency — holds consistently.

prefetch is the biggest single improvement: ~4× faster than cached without prefetch in this benchmark, because it overlaps data preparation with computation. The combined optimization delivers ~9× throughput compared to the naive baseline.

![TensorFlow data pipeline performance comparison chart](https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200&auto=format&fit=crop&q=80)

Conclusion

tf.data pipeline ordering is not arbitrary. The canonical sequence — shuffle → map → cache → batch → prefetch — ensures that each operation does its job at the right granularity: shuffle before caching so each epoch sees a different order, map before batching for per-sample operations, cache after expensive transforms, prefetch last to overlap with GPU computation. AUTOTUNE throughout removes the need to hand-tune thread counts and buffer sizes. The benchmark shows that a fully optimized pipeline delivers ~9× the throughput of the naive approach — on the same hardware, just from operation ordering.

The next post covers the Keras Functional API in depth — multi-input/output models, shared layers, and branching architectures that Sequential can't express.

Closing Takeaways

Measure retrieval precision and recall in isolation before touching the model.
Chunk along document structure, not arbitrary character counts.
Combine vector and keyword search — hybrid retrieval beats either alone.
Treat evaluation as continuous infrastructure, not a launch-week report.
Try It Yourself
A runnable Google Colab notebook with the eval harness and hybrid search code from this post.
#Enterprise RAG#Evaluation#Production AI#LangChain
0 views
SS
Soham Sharma
AI Engineer at Botmartz, building enterprise RAG and agent systems in production. Contributing to open-source libraries.

Discussion (0)

No approved comments yet. Be the first to share your thoughts!

Leave a Comment

Your email address will not be published. Required fields are marked *

More Engineering Insights
TensorFlow>-
Soham Sharma · 8 min read
GeneralPlaywright E2E Test Post
Integration Bot · 5 min read