Skill Tracer
Skills › Scientific Agent Skills › anndata › best_practices.md

best_practices.md

550 lines · skills/anndata/references/best_practices.md at 5f2bc03

Open on GitHub
1# Best Practices
2
3Guidelines reviewed for AnnData 0.13.4. User-file paths and application-specific `process`/`analyze` functions are illustrative. Small synthetic tests verify the data-structure and native-I/O contracts; they do not validate biological analysis.
4

Best Practices

Guidelines reviewed for AnnData 0.13.4. User-file paths and application-specific process/analyze functions are illustrative. Small synthetic tests verify the data-structure and native-I/O contracts; they do not validate biological analysis.

Raw lines 1 to 4
5## Memory Management
6

Memory Management

Raw lines 5 to 6
7### Use sparse matrices for sparse data
8```python
9import numpy as np
10from scipy.sparse import csr_matrix, csc_matrix
11import anndata as ad
13# Check data sparsity
14data = np.random.rand(1000, 2000)
15sparsity = 1 - np.count_nonzero(data) / data.size
16print(f"Sparsity: {sparsity:.2%}")
18# Convert to sparse if >50% zeros (anndata 0.12+ requires csr or csc)
19if sparsity > 0.5:
20 adata = ad.AnnData(X=csr_matrix(data))
21else:
22 adata = ad.AnnData(X=data)
24# Measure actual memory: data.nbytes versus CSR data/indices/indptr.nbytes.
25# Density, dtype, index width, and temporary arrays determine savings.
26```

Use sparse matrices for sparse data

import numpy as np
from scipy.sparse import csr_matrix, csc_matrix
import anndata as ad

# Check data sparsity
data = np.random.rand(1000, 2000)
sparsity = 1 - np.count_nonzero(data) / data.size
print(f"Sparsity: {sparsity:.2%}")

# Convert to sparse if >50% zeros (anndata 0.12+ requires csr or csc)
if sparsity > 0.5:
    adata = ad.AnnData(X=csr_matrix(data))
else:
    adata = ad.AnnData(X=data)

# Measure actual memory: data.nbytes versus CSR data/indices/indptr.nbytes.
# Density, dtype, index width, and temporary arrays determine savings.
Raw lines 7 to 27
28### Convert strings to categoricals
29```python
30# Inefficient: string columns use lots of memory
31adata.obs['cell_type'] = ['Type_A', 'Type_B', 'Type_C'] * 333 + ['Type_A']
33# Efficient: convert to categorical
34adata.obs['cell_type'] = adata.obs['cell_type'].astype('category')
36# Convert all string columns
37adata.strings_to_categoricals()
39# Check obs.memory_usage(deep=True); savings depend on category cardinality.
40```

Convert strings to categoricals

# Inefficient: string columns use lots of memory
adata.obs['cell_type'] = ['Type_A', 'Type_B', 'Type_C'] * 333 + ['Type_A']

# Efficient: convert to categorical
adata.obs['cell_type'] = adata.obs['cell_type'].astype('category')

# Convert all string columns
adata.strings_to_categoricals()

# Check obs.memory_usage(deep=True); savings depend on category cardinality.
Raw lines 28 to 41
42### Use backed mode for large datasets
43```python
44# Don't load entire dataset into memory
45adata = ad.read_h5ad('large_dataset.h5ad', backed='r')
47# Work with metadata
48filtered = adata[adata.obs['quality'] > 0.8]
50# Load only filtered subset
51adata_subset = filtered.to_memory()
53# Benefits: Work with datasets larger than RAM
54```

Use backed mode for large datasets

# Don't load entire dataset into memory
adata = ad.read_h5ad('large_dataset.h5ad', backed='r')

# Work with metadata
filtered = adata[adata.obs['quality'] > 0.8]

# Load only filtered subset
adata_subset = filtered.to_memory()

# Benefits: Work with datasets larger than RAM
Raw lines 42 to 55
56## Views vs Copies

Views vs Copies

Raw lines 56 to 57
58### Understanding views
59```python
60# Subsetting creates a view by default
61subset = adata[0:100, :]
62print(subset.is_view) # True
64# Views defer subset copies. In 0.13, .X also uses copy-on-write.
65# Modification can allocate a full subset copy; make ownership explicit.
67# Check if object is a view
68if adata.is_view:
69 adata = adata.copy() # Make independent
70```

Understanding views

# Subsetting creates a view by default
subset = adata[0:100, :]
print(subset.is_view)  # True

# Views defer subset copies. In 0.13, .X also uses copy-on-write.
# Modification can allocate a full subset copy; make ownership explicit.

# Check if object is a view
if adata.is_view:
    adata = adata.copy()  # Make independent
Raw lines 58 to 71
72### When to use views
73```python
74# Good: Read-only operations on subsets
75mean_expr = adata[adata.obs['cell_type'] == 'T cell'].X.mean()
77# Good: Temporary analysis
78temp_subset = adata[:100, :]
79result = analyze(temp_subset.X)
80```

When to use views

# Good: Read-only operations on subsets
mean_expr = adata[adata.obs['cell_type'] == 'T cell'].X.mean()

# Good: Temporary analysis
temp_subset = adata[:100, :]
result = analyze(temp_subset.X)
Raw lines 72 to 81
82### When to use copies
83```python
84# Create independent copy for modifications
85adata_filtered = adata[keep_cells, :].copy()
87# Safe to modify without affecting original
88adata_filtered.obs['new_column'] = values
90# Always copy when:
91# - Storing subset for later use
92# - Modifying subset data
93# - Passing to function that modifies data
94```

When to use copies

# Create independent copy for modifications
adata_filtered = adata[keep_cells, :].copy()

# Safe to modify without affecting original
adata_filtered.obs['new_column'] = values

# Always copy when:
# - Storing subset for later use
# - Modifying subset data
# - Passing to function that modifies data
Raw lines 82 to 95
96## Data Storage Best Practices

Data Storage Best Practices

Raw lines 96 to 97
98### Choose the right format
100**H5AD (HDF5) - Default choice**
101```python
102adata.write_h5ad('data.h5ad', compression='gzip')
103```
104- Fast random access
105- Supports backed mode
106- Good compression
107- Best for: Most use cases
109**Zarr - Cloud and parallel access**
110```python
111import anndata
113# AnnData 0.13.4 defaults to Zarr v3 with automatic sharding.
114adata.write_zarr('data.zarr', chunks=(100, 100))
115```
116- Excellent for cloud storage (S3, GCS)
117- Supports parallel I/O and default Zarr v3 sharding in 0.13.4
118- Good compression
119- Best for: Large datasets, cloud workflows, parallel processing
121**CSV - Interoperability**
122```python
123adata.write_csvs('output_dir/', skip_data=False)
124```
125- Human readable
126- Interoperable tabular export; loses parts of AnnData and may densify X
127- Large file sizes, slow
128- Best for: Sharing with non-Python tools, small datasets

Choose the right format

H5AD (HDF5) - Default choice

adata.write_h5ad('data.h5ad', compression='gzip')
  • Fast random access
  • Supports backed mode
  • Good compression
  • Best for: Most use cases

Zarr - Cloud and parallel access

import anndata

# AnnData 0.13.4 defaults to Zarr v3 with automatic sharding.
adata.write_zarr('data.zarr', chunks=(100, 100))
  • Excellent for cloud storage (S3, GCS)
  • Supports parallel I/O and default Zarr v3 sharding in 0.13.4
  • Good compression
  • Best for: Large datasets, cloud workflows, parallel processing

CSV - Interoperability

adata.write_csvs('output_dir/', skip_data=False)
  • Human readable
  • Interoperable tabular export; loses parts of AnnData and may densify X
  • Large file sizes, slow
  • Best for: Sharing with non-Python tools, small datasets
Raw lines 98 to 129
130### Optimize file size
131```python
132# Before saving, optimize:
134# 1. Convert to sparse if appropriate
135from scipy.sparse import csr_matrix, issparse
136if not issparse(adata.X):
137 density = np.count_nonzero(adata.X) / adata.X.size
138 if density < 0.5:
139 adata.X = csr_matrix(adata.X)
141# 2. Convert strings to categoricals
142adata.strings_to_categoricals()
144# 3. Use compression
145adata.write_h5ad('data.h5ad', compression='gzip', compression_opts=9)
147# Compare actual sizes and read/write times; there is no universal reduction factor.
148```

Optimize file size

# Before saving, optimize:

# 1. Convert to sparse if appropriate
from scipy.sparse import csr_matrix, issparse
if not issparse(adata.X):
    density = np.count_nonzero(adata.X) / adata.X.size
    if density < 0.5:
        adata.X = csr_matrix(adata.X)

# 2. Convert strings to categoricals
adata.strings_to_categoricals()

# 3. Use compression
adata.write_h5ad('data.h5ad', compression='gzip', compression_opts=9)

# Compare actual sizes and read/write times; there is no universal reduction factor.
Raw lines 130 to 149
150## Backed Mode Strategies

Backed Mode Strategies

Raw lines 150 to 151
152### Read-only analysis
153```python
154# Open in read-only backed mode
155adata = ad.read_h5ad('data.h5ad', backed='r')
157# Perform filtering without loading data
158high_quality = adata[adata.obs['quality_score'] > 0.8]
160# Load only filtered data
161adata_filtered = high_quality.to_memory()
162```

Read-only analysis

# Open in read-only backed mode
adata = ad.read_h5ad('data.h5ad', backed='r')

# Perform filtering without loading data
high_quality = adata[adata.obs['quality_score'] > 0.8]

# Load only filtered data
adata_filtered = high_quality.to_memory()
Raw lines 152 to 163
164### Read-write modifications
165```python
166# Open in read-write backed mode
167adata = ad.read_h5ad('data.h5ad', backed='r+')
169# Dense HDF5 X supports in-place writes; backed sparse X does not in 0.13.
170import h5py
171if isinstance(adata.X, h5py.Dataset):
172 adata.X[0, 0] = 0
174# Metadata changes are not persisted from backed mode; load and write a new file
175adata_memory = adata.to_memory()
176adata_memory.obs['new_annotation'] = values
177adata.file.close()
178adata_memory.write_h5ad('data_with_annotations.h5ad')
179```

Read-write modifications

# Open in read-write backed mode
adata = ad.read_h5ad('data.h5ad', backed='r+')

# Dense HDF5 X supports in-place writes; backed sparse X does not in 0.13.
import h5py
if isinstance(adata.X, h5py.Dataset):
    adata.X[0, 0] = 0

# Metadata changes are not persisted from backed mode; load and write a new file
adata_memory = adata.to_memory()
adata_memory.obs['new_annotation'] = values
adata.file.close()
adata_memory.write_h5ad('data_with_annotations.h5ad')
Raw lines 164 to 180
181### Chunked processing
182```python
183# Process large dataset in chunks
184adata = ad.read_h5ad('huge_dataset.h5ad', backed='r')
186results = []
187chunk_size = 1000
189for i in range(0, adata.n_obs, chunk_size):
190 chunk = adata[i:i+chunk_size, :].to_memory()
191 result = process(chunk)
192 results.append(result)
194final_result = combine(results)
195```

Chunked processing

# Process large dataset in chunks
adata = ad.read_h5ad('huge_dataset.h5ad', backed='r')

results = []
chunk_size = 1000

for i in range(0, adata.n_obs, chunk_size):
    chunk = adata[i:i+chunk_size, :].to_memory()
    result = process(chunk)
    results.append(result)

final_result = combine(results)
Raw lines 181 to 196
197## Performance Optimization

Performance Optimization

Raw lines 197 to 198
199### Subsetting performance
200```python
201# Boolean mask: clear and aligned to obs
202mask = np.array(adata.obs['quality'] > 0.5)
203subset = adata[mask, :]
205# A boolean Series is also supported; it does not inherently create a view chain
206subset = adata[adata.obs['quality'] > 0.5, :]
208# Integer positions: useful when already available; benchmark actual workloads
209indices = np.where(adata.obs['quality'] > 0.5)[0]
210subset = adata[indices, :]
211```

Subsetting performance

# Boolean mask: clear and aligned to obs
mask = np.array(adata.obs['quality'] > 0.5)
subset = adata[mask, :]

# A boolean Series is also supported; it does not inherently create a view chain
subset = adata[adata.obs['quality'] > 0.5, :]

# Integer positions: useful when already available; benchmark actual workloads
indices = np.where(adata.obs['quality'] > 0.5)[0]
subset = adata[indices, :]
Raw lines 199 to 212
213### Avoid repeated subsetting
214```python
215# Inefficient: Multiple subset operations
216for cell_type in ['A', 'B', 'C']:
217 subset = adata[adata.obs['cell_type'] == cell_type]
218 process(subset)
220# Alternative: reuse groups when several operations need the same grouping
221groups = adata.obs.groupby('cell_type', observed=True).groups
222for cell_type, indices in groups.items():
223 subset = adata[indices, :]
224 process(subset)
225```

Avoid repeated subsetting

# Inefficient: Multiple subset operations
for cell_type in ['A', 'B', 'C']:
    subset = adata[adata.obs['cell_type'] == cell_type]
    process(subset)

# Alternative: reuse groups when several operations need the same grouping
groups = adata.obs.groupby('cell_type', observed=True).groups
for cell_type, indices in groups.items():
    subset = adata[indices, :]
    process(subset)
Raw lines 213 to 226
227### Use chunked operations for large matrices
228```python
229# Process X in chunks
230for chunk, start, stop in adata.chunked_X(chunk_size=1000):
231 result = compute(chunk)
233# More memory efficient than loading full X
234```

Use chunked operations for large matrices

# Process X in chunks
for chunk, start, stop in adata.chunked_X(chunk_size=1000):
    result = compute(chunk)

# More memory efficient than loading full X
Raw lines 227 to 235
236## Working with Raw Data

Working with Raw Data

Raw lines 236 to 237
238### Store raw before filtering
239```python
240# Original data with all genes
241adata = ad.AnnData(X=counts)
243# Store raw before filtering
244adata.raw = adata.copy()
246# Filter to highly variable genes
247adata = adata[:, adata.var['highly_variable']]
249# Later: access original data
250original_expression = adata.raw.X
251all_genes = adata.raw.var_names
252```

Store raw before filtering

# Original data with all genes
adata = ad.AnnData(X=counts)

# Store raw before filtering
adata.raw = adata.copy()

# Filter to highly variable genes
adata = adata[:, adata.var['highly_variable']]

# Later: access original data
original_expression = adata.raw.X
all_genes = adata.raw.var_names
Raw lines 238 to 253
254### When to use raw
255```python
256# Use raw for:
257# - Differential expression on filtered genes
258# - Visualization of specific genes not in filtered set
259# - Accessing the snapshot values, which may be counts OR normalized data
260# Check raw_semantics; .raw is not automatically counts or a full object backup.
262# Access raw data
263if adata.raw is not None:
264 gene_expr = adata.raw[:, 'GENE_NAME'].X
265else:
266 gene_expr = adata[:, 'GENE_NAME'].X
267```

When to use raw

# Use raw for:
# - Differential expression on filtered genes
# - Visualization of specific genes not in filtered set
# - Accessing the snapshot values, which may be counts OR normalized data
# Check raw_semantics; .raw is not automatically counts or a full object backup.

# Access raw data
if adata.raw is not None:
    gene_expr = adata.raw[:, 'GENE_NAME'].X
else:
    gene_expr = adata[:, 'GENE_NAME'].X
Raw lines 254 to 268
269## Metadata Management

Metadata Management

Raw lines 269 to 270
271### Naming conventions
272```python
273# Consistent naming improves usability
275# Observation metadata (obs):
276# - cell_id, sample_id
277# - cell_type, tissue, condition
278# - n_genes, n_counts, percent_mito
279# - cluster, leiden, louvain
281# Variable metadata (var):
282# - gene_id, gene_name
283# - highly_variable, n_cells
284# - mean_expression, dispersion
286# Embeddings (obsm):
287# - X_pca, X_umap, X_tsne
288# - X_diffmap, X_draw_graph_fr
290# Follow conventions from scanpy/scverse ecosystem
291```

Naming conventions

# Consistent naming improves usability

# Observation metadata (obs):
# - cell_id, sample_id
# - cell_type, tissue, condition
# - n_genes, n_counts, percent_mito
# - cluster, leiden, louvain

# Variable metadata (var):
# - gene_id, gene_name
# - highly_variable, n_cells
# - mean_expression, dispersion

# Embeddings (obsm):
# - X_pca, X_umap, X_tsne
# - X_diffmap, X_draw_graph_fr

# Follow conventions from scanpy/scverse ecosystem
Raw lines 271 to 292
293### Document metadata
294```python
295# Store metadata descriptions in uns
296adata.uns['metadata_descriptions'] = {
297 'cell_type': 'Cell type annotation from automated clustering',
298 'doublet_score': 'Scrublet predicted doublet score; higher suggests a doublet',
299 'batch': 'Experimental batch identifier'
302# Store processing history
303adata.uns['processing_steps'] = [
304 'Raw counts loaded from 10X',
305 'Filtered: n_genes > 200, n_counts < 50000',
306 'Normalized to 10000 counts per cell',
307 'Log transformed'
309```

Document metadata

# Store metadata descriptions in uns
adata.uns['metadata_descriptions'] = {
    'cell_type': 'Cell type annotation from automated clustering',
    'doublet_score': 'Scrublet predicted doublet score; higher suggests a doublet',
    'batch': 'Experimental batch identifier'
}

# Store processing history
adata.uns['processing_steps'] = [
    'Raw counts loaded from 10X',
    'Filtered: n_genes > 200, n_counts < 50000',
    'Normalized to 10000 counts per cell',
    'Log transformed'
]
Raw lines 293 to 310
311## Reproducibility

Reproducibility

Raw lines 311 to 312
313### Set random seeds
314```python
315import numpy as np
317# Set seed for reproducible results
318np.random.seed(42)
320# Document in uns
321adata.uns['random_seed'] = 42
322```

Set random seeds

import numpy as np

# Set seed for reproducible results
np.random.seed(42)

# Document in uns
adata.uns['random_seed'] = 42
Raw lines 313 to 323
324### Store parameters
325```python
326# Store analysis parameters in uns
327adata.uns['pca'] = {
328 'n_comps': 50,
329 'svd_solver': 'arpack',
330 'random_state': 42
333adata.uns['neighbors'] = {
334 'n_neighbors': 15,
335 'n_pcs': 50,
336 'metric': 'euclidean',
337 'method': 'umap'
339```

Store parameters

# Store analysis parameters in uns
adata.uns['pca'] = {
    'n_comps': 50,
    'svd_solver': 'arpack',
    'random_state': 42
}

adata.uns['neighbors'] = {
    'n_neighbors': 15,
    'n_pcs': 50,
    'metric': 'euclidean',
    'method': 'umap'
}
Raw lines 324 to 340
341### Version tracking
342```python
343import sys
344from importlib.metadata import version
346# Store package versions (anndata.__version__ deprecated in 0.12.3)
347adata.uns['versions'] = {
348 'anndata': version('anndata'),
349 'scanpy': version('scanpy'),
350 'numpy': version('numpy'),
351 'python': sys.version,
353```

Version tracking

import sys
from importlib.metadata import version

# Store package versions (anndata.__version__ deprecated in 0.12.3)
adata.uns['versions'] = {
    'anndata': version('anndata'),
    'scanpy': version('scanpy'),
    'numpy': version('numpy'),
    'python': sys.version,
}
Raw lines 341 to 354
355## Error Handling

Error Handling

Raw lines 355 to 356
357### Check data validity
358```python
359# Verify dimensions
360assert adata.n_obs == len(adata.obs)
361assert adata.n_vars == len(adata.var)
362assert adata.X.shape == (adata.n_obs, adata.n_vars)
364# Check for NaN values
365has_nan = np.isnan(adata.X.data).any() if issparse(adata.X) else np.isnan(adata.X).any()
366if has_nan:
367 print("Warning: Data contains NaN values")
369# Check for negative values (if counts expected)
370has_negative = (adata.X.data < 0).any() if issparse(adata.X) else (adata.X < 0).any()
371if has_negative:
372 print("Warning: Data contains negative values")
373```

Check data validity

# Verify dimensions
assert adata.n_obs == len(adata.obs)
assert adata.n_vars == len(adata.var)
assert adata.X.shape == (adata.n_obs, adata.n_vars)

# Check for NaN values
has_nan = np.isnan(adata.X.data).any() if issparse(adata.X) else np.isnan(adata.X).any()
if has_nan:
    print("Warning: Data contains NaN values")

# Check for negative values (if counts expected)
has_negative = (adata.X.data < 0).any() if issparse(adata.X) else (adata.X < 0).any()
if has_negative:
    print("Warning: Data contains negative values")
Raw lines 357 to 374
375### Validate metadata
376```python
377# Check for missing values
378missing_obs = adata.obs.isnull().sum()
379if missing_obs.any():
380 print("Missing values in obs:")
381 print(missing_obs[missing_obs > 0])
383# Verify indices are unique
384assert adata.obs_names.is_unique, "Observation names not unique"
385assert adata.var_names.is_unique, "Variable names not unique"
387# Check metadata alignment
388assert len(adata.obs) == adata.n_obs
389assert len(adata.var) == adata.n_vars
390```

Validate metadata

# Check for missing values
missing_obs = adata.obs.isnull().sum()
if missing_obs.any():
    print("Missing values in obs:")
    print(missing_obs[missing_obs > 0])

# Verify indices are unique
assert adata.obs_names.is_unique, "Observation names not unique"
assert adata.var_names.is_unique, "Variable names not unique"

# Check metadata alignment
assert len(adata.obs) == adata.n_obs
assert len(adata.var) == adata.n_vars
Raw lines 375 to 391
392## Integration with Other Tools

Integration with Other Tools

Raw lines 392 to 393
394### Scanpy integration
395```python
396import scanpy as sc
398# AnnData is native format for scanpy
399sc.pp.filter_cells(adata, min_genes=200)
400sc.pp.filter_genes(adata, min_cells=3)
401sc.pp.normalize_total(adata, target_sum=1e4)
402sc.pp.log1p(adata)
403sc.pp.highly_variable_genes(adata)
404sc.pp.pca(adata)
405sc.pp.neighbors(adata)
406sc.tl.umap(adata)
407```

Scanpy integration

import scanpy as sc

# AnnData is native format for scanpy
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=3)
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata)
sc.pp.pca(adata)
sc.pp.neighbors(adata)
sc.tl.umap(adata)
Raw lines 394 to 408
409### Pandas integration
410```python
411import pandas as pd
413# Convert only a manageable subset: to_df densifies sparse data
414df = adata[:100, :100].to_df()
416# Create from DataFrame
417adata = ad.AnnData(df)
419# Work with metadata as DataFrames
420assert external_metadata.index.is_unique
421adata.obs = adata.obs.join(external_metadata, how='left', validate='one_to_one')
422```

Pandas integration

import pandas as pd

# Convert only a manageable subset: to_df densifies sparse data
df = adata[:100, :100].to_df()

# Create from DataFrame
adata = ad.AnnData(df)

# Work with metadata as DataFrames
assert external_metadata.index.is_unique
adata.obs = adata.obs.join(external_metadata, how='left', validate='one_to_one')
Raw lines 409 to 423
424### PyTorch integration
426`AnnLoader` is deprecated; use the official [annbatch.Loader migration tutorial](https://anndata.readthedocs.io/en/stable/tutorials/notebooks/annbatch.html).
427No training, accelerator, or third-party version combination was executed here.

PyTorch integration

AnnLoader is deprecated; use the official annbatch.Loader migration tutorial. No training, accelerator, or third-party version combination was executed here.

Raw lines 424 to 428
429## Common Pitfalls

Common Pitfalls

Raw lines 429 to 430
431### Pitfall 1: Modifying views
432```python
433# In 0.13, assigning .X to a view materializes that view (copy-on-write).
434subset = adata[:100, :]
435subset.X = new_data # Can allocate unexpectedly; older releases behaved differently
437# Correct: Copy before modifying
438subset = adata[:100, :].copy()
439subset.X = new_data # Independent copy
440```

Pitfall 1: Modifying views

# In 0.13, assigning .X to a view materializes that view (copy-on-write).
subset = adata[:100, :]
subset.X = new_data  # Can allocate unexpectedly; older releases behaved differently

# Correct: Copy before modifying
subset = adata[:100, :].copy()
subset.X = new_data  # Independent copy
Raw lines 431 to 441
442### Pitfall 2: Index misalignment
443```python
444# Wrong: Assuming order matches
445external_data = pd.read_csv('data.csv')
446adata.obs['new_col'] = external_data['values'] # May misalign!
448# Correct: Align on index
449adata.obs['new_col'] = external_data.set_index('cell_id').loc[adata.obs_names, 'values']
450```

Pitfall 2: Index misalignment

# Wrong: Assuming order matches
external_data = pd.read_csv('data.csv')
adata.obs['new_col'] = external_data['values']  # May misalign!

# Correct: Align on index
adata.obs['new_col'] = external_data.set_index('cell_id').loc[adata.obs_names, 'values']
Raw lines 442 to 451
452### Pitfall 3: Mixing sparse and dense
453```python
454# SciPy sparse + nonzero scalar raises NotImplementedError.
455# Adding 1 to .data alone changes only stored entries, NOT every matrix element.
456# A zero-preserving operation such as log1p can safely preserve sparsity:
457from scipy.sparse import issparse
458if issparse(adata.X):
459 result = adata.X.copy()
460 result.data = np.log1p(result.data)
461else:
462 result = np.log1p(adata.X)
463```

Pitfall 3: Mixing sparse and dense

# SciPy sparse + nonzero scalar raises NotImplementedError.
# Adding 1 to .data alone changes only stored entries, NOT every matrix element.
# A zero-preserving operation such as log1p can safely preserve sparsity:
from scipy.sparse import issparse
if issparse(adata.X):
    result = adata.X.copy()
    result.data = np.log1p(result.data)
else:
    result = np.log1p(adata.X)
Raw lines 452 to 464
465### Pitfall 4: Not handling views
466```python
467# An in-memory view keeps its parent alive even if the local name is deleted.
468subset = adata[mask, :]
469del adata
470# Copy when independent ownership is required:
471subset = subset.copy()
472# For backed data, keep the HDF5 file open until .to_memory() completes.
473```

Pitfall 4: Not handling views

# An in-memory view keeps its parent alive even if the local name is deleted.
subset = adata[mask, :]
del adata
# Copy when independent ownership is required:
subset = subset.copy()
# For backed data, keep the HDF5 file open until .to_memory() completes.
Raw lines 465 to 474
475### Pitfall 5: Ignoring memory constraints
476```python
477# Wrong: Loading huge dataset into memory
478adata = ad.read_h5ad('100GB_file.h5ad') # OOM error!
480# Correct: Use backed mode
481adata = ad.read_h5ad('100GB_file.h5ad', backed='r')
482subset = adata[adata.obs['keep']].to_memory()
483```

Pitfall 5: Ignoring memory constraints

# Wrong: Loading huge dataset into memory
adata = ad.read_h5ad('100GB_file.h5ad')  # OOM error!

# Correct: Use backed mode
adata = ad.read_h5ad('100GB_file.h5ad', backed='r')
subset = adata[adata.obs['keep']].to_memory()
Raw lines 475 to 484
485## Workflow Example
487Complete best-practices workflow:
489```python
490import anndata as ad
491import numpy as np
492from scipy.sparse import csr_matrix, issparse
494# 1. Load with backed mode if large
495source = ad.read_h5ad('data.h5ad', backed='r')
496adata = source
498# 2. Quick metadata check without loading data
499print(f"Dataset: {adata.n_obs} cells × {adata.n_vars} genes")
501# 3. Filter based on metadata
502high_quality = adata[adata.obs['quality_score'] > 0.8]
504# 4. Load filtered subset to memory
505adata = high_quality.to_memory()
506source.file.close()
508# 5. Convert to optimal storage types (csr/csc sparse only since 0.12)
509adata.strings_to_categoricals()
510if not issparse(adata.X):
511 density = np.count_nonzero(adata.X) / adata.X.size
512 if density < 0.5:
513 adata.X = csr_matrix(adata.X)
515# 6. Store raw before filtering genes
516adata.raw = adata.copy()
518# 7. Filter to highly variable genes
519adata = adata[:, adata.var['highly_variable']].copy()
521# 8. Document processing
522adata.uns['processing'] = {
523 'filtered': 'quality_score > 0.8',
524 'n_hvg': adata.n_vars,
525 'matrix_semantics': 'retain and document input transformations'
528# 9. Save optimized
529adata.write_h5ad('processed.h5ad', compression='gzip')
530```

Workflow Example

Complete best-practices workflow:

import anndata as ad
import numpy as np
from scipy.sparse import csr_matrix, issparse

# 1. Load with backed mode if large
source = ad.read_h5ad('data.h5ad', backed='r')
adata = source

# 2. Quick metadata check without loading data
print(f"Dataset: {adata.n_obs} cells × {adata.n_vars} genes")

# 3. Filter based on metadata
high_quality = adata[adata.obs['quality_score'] > 0.8]

# 4. Load filtered subset to memory
adata = high_quality.to_memory()
source.file.close()

# 5. Convert to optimal storage types (csr/csc sparse only since 0.12)
adata.strings_to_categoricals()
if not issparse(adata.X):
    density = np.count_nonzero(adata.X) / adata.X.size
    if density < 0.5:
        adata.X = csr_matrix(adata.X)

# 6. Store raw before filtering genes
adata.raw = adata.copy()

# 7. Filter to highly variable genes
adata = adata[:, adata.var['highly_variable']].copy()

# 8. Document processing
adata.uns['processing'] = {
    'filtered': 'quality_score > 0.8',
    'n_hvg': adata.n_vars,
    'matrix_semantics': 'retain and document input transformations'
}

# 9. Save optimized
adata.write_h5ad('processed.h5ad', compression='gzip')
Raw lines 485 to 531
532## Provenance and compatibility
534- `raw` retains its feature axis on gene filtering, but follows cell filtering. Named
535 layers follow both axes. Store counts separately if later analyses need all genes.
536- AnnData 0.13 includes X as `layers[None]`; use `key is not None` for named-layer
537 iteration and `layers.clear(keep_x=True)` to retain X. Check third-party exporters
538 before upgrading: this change breaks some older string-key assumptions.
539- Keep stable feature IDs separate from symbols; making duplicate names unique is
540 a syntactic repair, not biological deduplication. Record species and genome build.
541- H5AD backed reads can still load metadata and layers; Zarr `read_zarr` is eager.
542 Budget memory before `to_memory`, `to_df`, transposition, scaling, or dense export.
543- Reopen native output and compare identifiers in order, dtypes, categories, selected
544 matrix values, named layers, raw gene IDs, and source/transformation provenance.
546Sources: [AnnData 0.13 release notes](https://anndata.readthedocs.io/en/stable/release-notes/),
547[raw](https://anndata.readthedocs.io/en/stable/generated/anndata.AnnData.raw.html),
548[write_csvs](https://anndata.readthedocs.io/en/stable/generated/anndata.AnnData.write_csvs.html),
549[read_h5ad](https://anndata.readthedocs.io/en/stable/generated/anndata.io.read_h5ad.html),
550and installed AnnData 0.13.4 source.

Provenance and compatibility

  • raw retains its feature axis on gene filtering, but follows cell filtering. Named layers follow both axes. Store counts separately if later analyses need all genes.
  • AnnData 0.13 includes X as layers[None]; use key is not None for named-layer iteration and layers.clear(keep_x=True) to retain X. Check third-party exporters before upgrading: this change breaks some older string-key assumptions.
  • Keep stable feature IDs separate from symbols; making duplicate names unique is a syntactic repair, not biological deduplication. Record species and genome build.
  • H5AD backed reads can still load metadata and layers; Zarr read_zarr is eager. Budget memory before to_memory, to_df, transposition, scaling, or dense export.
  • Reopen native output and compare identifiers in order, dtypes, categories, selected matrix values, named layers, raw gene IDs, and source/transformation provenance.

Sources: AnnData 0.13 release notes, raw, write_csvs, read_h5ad, and installed AnnData 0.13.4 source.

Raw lines 532 to 550