PEP 526: Variable Annotations - Type Hints for Variables
TL;DR
PEP 526 introduced variable annotations in Python 3.6, allowing type hints for variables using the syntax variable: type = value
, extending PEP 484’s function annotations to all variables for better code documentation and static analysis.
Interesting!
Variable annotations create a special __annotations__
dictionary on classes and modules that stores the type information at runtime, making it accessible for tools, IDEs, and introspection - but the annotations themselves don’t affect runtime behavior!
Variable Annotation Syntax
Basic Variable Annotations
python code snippet start
# Simple variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.6
is_active: bool = True
# Annotations without initial values
user_id: int
email: str
profile_data: dict
# Later assignment
user_id = 12345
email = "alice@example.com"
profile_data = {"theme": "dark", "notifications": True}
# Multiple annotations in sequence
first_name: str
last_name: str
full_name: str
first_name = "Alice"
last_name = "Smith"
full_name = f"{first_name} {last_name}"
python code snippet end
Complex Type Annotations
python code snippet start
from typing import List, Dict, Optional, Union, Tuple, Set
# Collection types
numbers: List[int] = [1, 2, 3, 4, 5]
scores: Dict[str, float] = {"math": 95.5, "science": 87.2}
coordinates: Tuple[float, float] = (10.5, 20.3)
unique_tags: Set[str] = {"python", "programming", "tutorial"}
# Optional and Union types
middle_name: Optional[str] = None # Can be str or None
result: Union[str, int] = "success" # Can be str or int
# Nested collections
matrix: List[List[int]] = [[1, 2], [3, 4], [5, 6]]
user_groups: Dict[str, List[str]] = {
"admins": ["alice", "bob"],
"users": ["charlie", "diana"]
}
# Function types
from typing import Callable
callback: Callable[[int, str], bool] = lambda x, y: len(y) > x
processor: Callable[..., None] # Variable arguments
python code snippet end
Class Variable Annotations
Instance vs Class Variables
python code snippet start
from typing import ClassVar, List, Optional
class User:
# Class variable annotations
total_users: ClassVar[int] = 0
default_role: ClassVar[str] = "user"
# Instance variable annotations
username: str
email: str
age: Optional[int]
roles: List[str]
def __init__(self, username: str, email: str):
self.username = username
self.email = email
self.age = None # Optional, can be set later
self.roles = []
# Update class variable
User.total_users += 1
def add_role(self, role: str) -> None:
self.roles.append(role)
def get_display_name(self) -> str:
return f"{self.username} ({self.email})"
# Usage
alice = User("alice", "alice@example.com")
alice.age = 30
alice.add_role("admin")
print(f"Total users: {User.total_users}")
print(f"Alice's info: {alice.get_display_name()}")
python code snippet end
Advanced Class Annotations
python code snippet start
from typing import Dict, Any, Protocol, TypeVar
from dataclasses import dataclass, field
# Protocol for type checking
class Drawable(Protocol):
def draw(self) -> str: ...
# Generic type variable
T = TypeVar('T')
@dataclass
class Repository:
"""Generic repository with type annotations."""
# Class variables
connection_pool: ClassVar[Dict[str, Any]] = {}
# Instance variables with defaults
name: str
items: List[T] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
is_readonly: bool = False
def add_item(self, item: T) -> None:
if not self.is_readonly:
self.items.append(item)
def get_items(self) -> List[T]:
return self.items.copy()
def count(self) -> int:
return len(self.items)
# Specialized repositories
user_repo: Repository[User] = Repository("users")
config_repo: Repository[Dict[str, str]] = Repository("config")
user_repo.add_item(alice)
config_repo.add_item({"theme": "dark", "language": "en"})
python code snippet end
Module-Level Annotations
Global Variable Annotations
python code snippet start
# Module-level variable annotations
from typing import Dict, List, Optional
import os
# Configuration variables
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
MAX_CONNECTIONS: int = int(os.getenv("MAX_CONNECTIONS", "100"))
DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL")
# Application state
active_sessions: Dict[str, Dict[str, Any]] = {}
request_queue: List[Dict[str, Any]] = []
error_log: List[str] = []
# Cached data
_cache: Dict[str, Any] = {}
_cache_expiry: Optional[float] = None
def initialize_app() -> None:
"""Initialize application with proper type checking."""
global active_sessions, request_queue
active_sessions.clear()
request_queue.clear()
if DEBUG:
print("Application initialized in debug mode")
def add_session(session_id: str, user_data: Dict[str, Any]) -> None:
"""Add a new session with type safety."""
active_sessions[session_id] = {
"user_data": user_data,
"created_at": time.time(),
"last_activity": time.time()
}
# Check annotations at module level
print(f"Module annotations: {__annotations__}")
python code snippet end
Configuration Management
python code snippet start
from typing import NamedTuple, Literal, get_type_hints
import json
from pathlib import Path
# Type-safe configuration using annotations
class AppConfig:
"""Application configuration with type annotations."""
# Server settings
host: str = "localhost"
port: int = 8000
debug: bool = False
# Database settings
db_host: str = "localhost"
db_port: int = 5432
db_name: str = "myapp"
db_user: str = "postgres"
db_password: str = ""
# Feature flags
enable_caching: bool = True
enable_logging: bool = True
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
# Limits
max_upload_size: int = 10 * 1024 * 1024 # 10MB
session_timeout: int = 3600 # 1 hour
@classmethod
def from_file(cls, config_path: Path) -> 'AppConfig':
"""Load configuration from JSON file with type validation."""
config = cls()
if config_path.exists():
with open(config_path) as f:
data = json.load(f)
# Get type hints for validation
hints = get_type_hints(cls)
for key, value in data.items():
if hasattr(config, key):
expected_type = hints.get(key)
if expected_type and not isinstance(value, expected_type):
# Simple type conversion for basic types
if expected_type == bool and isinstance(value, str):
value = value.lower() in ('true', '1', 'yes')
elif expected_type in (int, float, str):
value = expected_type(value)
setattr(config, key, value)
return config
def to_dict(self) -> Dict[str, Any]:
"""Convert config to dictionary."""
return {
key: getattr(self, key)
for key in get_type_hints(self.__class__)
if not key.startswith('_')
}
# Usage
config: AppConfig = AppConfig.from_file(Path("config.json"))
print(f"Server will run on {config.host}:{config.port}")
print(f"Debug mode: {config.debug}")
python code snippet end
Runtime Introspection of Annotations
Accessing Annotations
python code snippet start
from typing import get_type_hints, get_origin, get_args
import inspect
class DataProcessor:
"""Example class with comprehensive annotations."""
batch_size: int = 100
input_format: str = "json"
processors: List[Callable[[Dict], Dict]] = []
def __init__(self, name: str):
self.name: str = name
self.processed_count: int = 0
self.errors: List[str] = []
def analyze_annotations(obj: Any) -> Dict[str, Any]:
"""Analyze annotations of an object."""
result = {}
# Get annotations dictionary
if hasattr(obj, '__annotations__'):
result['raw_annotations'] = obj.__annotations__
# Get resolved type hints
try:
result['type_hints'] = get_type_hints(obj)
except (NameError, AttributeError):
result['type_hints'] = {}
# Analyze each annotation
detailed_info = {}
for name, annotation in result.get('type_hints', {}).items():
info = {
'annotation': annotation,
'origin': get_origin(annotation),
'args': get_args(annotation),
'is_generic': hasattr(annotation, '__origin__'),
}
# Check if it has a default value
if hasattr(obj, name):
info['has_default'] = True
info['default_value'] = getattr(obj, name)
else:
info['has_default'] = False
detailed_info[name] = info
result['detailed_info'] = detailed_info
return result
# Analyze our class
processor_info = analyze_annotations(DataProcessor)
print("DataProcessor annotations analysis:")
for name, info in processor_info['detailed_info'].items():
print(f" {name}: {info['annotation']}")
if info['has_default']:
print(f" Default: {info['default_value']}")
if info['origin']:
print(f" Origin: {info['origin']}, Args: {info['args']}")
python code snippet end
Dynamic Type Checking
python code snippet start
from typing import Any, Union, get_origin, get_args
import inspect
def validate_annotation(value: Any, annotation: Any) -> bool:
"""Validate if a value matches its annotation."""
# Handle None/Optional
if value is None:
return annotation is type(None) or (
get_origin(annotation) is Union and
type(None) in get_args(annotation)
)
# Handle basic types
if isinstance(annotation, type):
return isinstance(value, annotation)
# Handle generic types
origin = get_origin(annotation)
args = get_args(annotation)
if origin is Union:
return any(validate_annotation(value, arg) for arg in args)
if origin is list:
if not isinstance(value, list):
return False
if args: # Check element types
return all(validate_annotation(item, args[0]) for item in value)
return True
if origin is dict:
if not isinstance(value, dict):
return False
if len(args) == 2: # Check key and value types
key_type, value_type = args
return all(
validate_annotation(k, key_type) and validate_annotation(v, value_type)
for k, v in value.items()
)
return True
# Default: try isinstance
try:
return isinstance(value, annotation)
except TypeError:
return True # Can't validate, assume valid
class TypedContainer:
"""Container that validates types based on annotations."""
def __init__(self):
self._annotations = get_type_hints(self.__class__)
def __setattr__(self, name: str, value: Any) -> None:
# Skip private attributes and during initialization
if name.startswith('_') or '_annotations' not in self.__dict__:
super().__setattr__(name, value)
return
# Validate against annotation if it exists
if name in self._annotations:
expected_type = self._annotations[name]
if not validate_annotation(value, expected_type):
raise TypeError(
f"Invalid type for {name}: expected {expected_type}, got {type(value)}"
)
super().__setattr__(name, value)
class StrictUser(TypedContainer):
"""User class with runtime type checking."""
name: str
age: int
emails: List[str]
metadata: Optional[Dict[str, Any]]
def __init__(self, name: str, age: int):
super().__init__()
self.name = name
self.age = age
self.emails = []
self.metadata = None
# Test the validation
try:
user = StrictUser("Alice", 30)
user.emails = ["alice@example.com", "alice@work.com"] # Valid
user.age = "thirty" # This will raise TypeError
except TypeError as e:
print(f"Type error caught: {e}")
python code snippet end
Integration with Type Checkers
MyPy Integration
python code snippet start
# mypy_example.py
from typing import List, Dict, Optional, Protocol
import json
# Protocol definition
class Serializable(Protocol):
def to_dict(self) -> Dict[str, Any]: ...
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Serializable': ...
class Product:
"""Product class with comprehensive type annotations."""
# Class variables
next_id: int = 1
def __init__(self, name: str, price: float, category: str):
self.id: int = Product.next_id
Product.next_id += 1
self.name: str = name
self.price: float = price
self.category: str = category
self.tags: List[str] = []
self.metadata: Dict[str, Any] = {}
self.is_active: bool = True
def add_tag(self, tag: str) -> None:
if tag not in self.tags:
self.tags.append(tag)
def set_metadata(self, key: str, value: Any) -> None:
self.metadata[key] = value
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'name': self.name,
'price': self.price,
'category': self.category,
'tags': self.tags,
'metadata': self.metadata,
'is_active': self.is_active
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Product':
product = cls(data['name'], data['price'], data['category'])
product.id = data.get('id', product.id)
product.tags = data.get('tags', [])
product.metadata = data.get('metadata', {})
product.is_active = data.get('is_active', True)
return product
class ProductManager:
"""Manager for products with type safety."""
def __init__(self):
self.products: Dict[int, Product] = {}
self.categories: Set[str] = set()
def add_product(self, product: Product) -> None:
self.products[product.id] = product
self.categories.add(product.category)
def get_product(self, product_id: int) -> Optional[Product]:
return self.products.get(product_id)
def get_products_by_category(self, category: str) -> List[Product]:
return [
product for product in self.products.values()
if product.category == category and product.is_active
]
def save_to_file(self, filename: str) -> None:
data = {
'products': [product.to_dict() for product in self.products.values()],
'categories': list(self.categories)
}
with open(filename, 'w') as f:
json.dump(data, f, indent=2)
@classmethod
def load_from_file(cls, filename: str) -> 'ProductManager':
manager = cls()
try:
with open(filename, 'r') as f:
data = json.load(f)
for product_data in data.get('products', []):
product = Product.from_dict(product_data)
manager.add_product(product)
except FileNotFoundError:
pass # Start with empty manager
return manager
# Usage with type checking
manager: ProductManager = ProductManager()
laptop: Product = Product("MacBook Pro", 2499.99, "computers")
laptop.add_tag("apple")
laptop.add_tag("laptop")
manager.add_product(laptop)
python code snippet end
IDE Support and Tooling
python code snippet start
# ide_support_example.py
from typing import TYPE_CHECKING, TypeVar, Generic, overload
# Import for type checking only (not at runtime)
if TYPE_CHECKING:
from expensive_module import HeavyObject
# Generic type variables
K = TypeVar('K') # Key type
V = TypeVar('V') # Value type
class Cache(Generic[K, V]):
"""Generic cache with type annotations for IDE support."""
def __init__(self, max_size: int = 100):
self._data: Dict[K, V] = {}
self._access_order: List[K] = []
self.max_size: int = max_size
def get(self, key: K) -> Optional[V]:
"""Get value by key with type safety."""
if key in self._data:
# Move to end (most recently used)
self._access_order.remove(key)
self._access_order.append(key)
return self._data[key]
return None
def put(self, key: K, value: V) -> None:
"""Put value with automatic eviction."""
if key in self._data:
self._access_order.remove(key)
elif len(self._data) >= self.max_size:
# Evict least recently used
lru_key = self._access_order.pop(0)
del self._data[lru_key]
self._data[key] = value
self._access_order.append(key)
@overload
def get_or_default(self, key: K, default: V) -> V: ...
@overload
def get_or_default(self, key: K, default: None = None) -> Optional[V]: ...
def get_or_default(self, key: K, default: Optional[V] = None) -> Optional[V]:
"""Get value or return default."""
return self.get(key) or default
# Usage with full IDE support
string_cache: Cache[str, str] = Cache()
int_cache: Cache[int, List[str]] = Cache(max_size=50)
string_cache.put("greeting", "Hello, World!")
greeting: Optional[str] = string_cache.get("greeting") # IDE knows this is Optional[str]
int_cache.put(1, ["apple", "banana"])
fruits: Optional[List[str]] = int_cache.get(1) # IDE knows this is Optional[List[str]]
python code snippet end
Best Practices and Patterns
Gradual Typing Strategy
python code snippet start
# gradual_typing.py - Converting legacy code to use annotations
from typing import Any, Dict, List, Optional, Union, cast
# Legacy function without annotations
def process_data_legacy(data, config, options=None):
"""Legacy function without type hints."""
if options is None:
options = {}
result = []
for item in data:
if item.get('active', True):
processed = transform_item(item, config)
result.append(processed)
return result
# Step 1: Add basic annotations
def process_data_basic(
data: List[Dict[str, Any]],
config: Dict[str, Any],
options: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Function with basic type annotations."""
if options is None:
options = {}
result: List[Dict[str, Any]] = []
for item in data:
if item.get('active', True):
processed = transform_item(item, config)
result.append(processed)
return result
# Step 2: Refine with more specific types
from typing import TypedDict
class DataItem(TypedDict, total=False):
id: int
name: str
active: bool
value: float
metadata: Dict[str, Any]
class ProcessConfig(TypedDict):
multiplier: float
format: str
validation: bool
class ProcessOptions(TypedDict, total=False):
debug: bool
timeout: int
retry_count: int
def process_data_refined(
data: List[DataItem],
config: ProcessConfig,
options: Optional[ProcessOptions] = None
) -> List[DataItem]:
"""Function with refined type annotations."""
if options is None:
options = {}
result: List[DataItem] = []
for item in data:
if item.get('active', True):
processed = transform_item_typed(item, config)
result.append(processed)
return result
def transform_item_typed(item: DataItem, config: ProcessConfig) -> DataItem:
"""Transform a single item with type safety."""
transformed: DataItem = item.copy()
if 'value' in item:
transformed['value'] = item['value'] * config['multiplier']
return transformed
python code snippet end
Forward References and Circular Dependencies
python code snippet start
# forward_references.py
from __future__ import annotations # Enable string annotations
from typing import Optional, List, TYPE_CHECKING
if TYPE_CHECKING:
from .related_module import RelatedClass
class Node:
"""Tree node with forward reference to itself."""
def __init__(self, value: int, parent: Optional[Node] = None):
self.value: int = value
self.parent: Optional[Node] = parent
self.children: List[Node] = []
self.related: Optional[RelatedClass] = None
def add_child(self, child: Node) -> None:
child.parent = self
self.children.append(child)
def get_ancestors(self) -> List[Node]:
ancestors: List[Node] = []
current = self.parent
while current:
ancestors.append(current)
current = current.parent
return ancestors
def find_by_value(self, value: int) -> Optional[Node]:
if self.value == value:
return self
for child in self.children:
result = child.find_by_value(value)
if result:
return result
return None
class Tree:
"""Tree structure with proper type annotations."""
def __init__(self, root_value: int):
self.root: Node = Node(root_value)
self.node_count: int = 1
def insert(self, value: int, parent_value: int) -> bool:
parent = self.root.find_by_value(parent_value)
if parent:
new_node = Node(value)
parent.add_child(new_node)
self.node_count += 1
return True
return False
def traverse_dfs(self) -> List[int]:
def dfs(node: Node) -> List[int]:
result = [node.value]
for child in node.children:
result.extend(dfs(child))
return result
return dfs(self.root)
python code snippet end
Error Handling with Annotations
python code snippet start
# error_handling.py
from typing import Union, TypeVar, Generic, Callable, Any
from dataclasses import dataclass
T = TypeVar('T')
E = TypeVar('E', bound=Exception)
@dataclass
class Result(Generic[T, E]):
"""Result type for error handling without exceptions."""
def __init__(self, value: T = None, error: E = None):
self._value = value
self._error = error
@property
def is_success(self) -> bool:
return self._error is None
@property
def is_error(self) -> bool:
return self._error is not None
def unwrap(self) -> T:
if self._error:
raise self._error
return self._value
def unwrap_or(self, default: T) -> T:
return self._value if self.is_success else default
def map(self, func: Callable[[T], Any]) -> Result[Any, E]:
if self.is_success:
try:
return Result(func(self._value))
except Exception as e:
return Result(error=e)
return Result(error=self._error)
def safe_divide(a: float, b: float) -> Result[float, ValueError]:
"""Safe division with error handling."""
if b == 0:
return Result(error=ValueError("Division by zero"))
return Result(a / b)
def safe_parse_int(value: str) -> Result[int, ValueError]:
"""Safe integer parsing."""
try:
return Result(int(value))
except ValueError as e:
return Result(error=e)
# Usage with type safety
result1: Result[float, ValueError] = safe_divide(10, 2)
if result1.is_success:
print(f"Division result: {result1.unwrap()}")
result2: Result[int, ValueError] = safe_parse_int("abc")
parsed_value: int = result2.unwrap_or(0) # Safe with default
python code snippet end
Variable annotations in PEP 526 bridge the gap between documentation and type safety, enabling better tooling support while maintaining Python’s dynamic nature and runtime flexibility.
This PEP builds directly on PEP 484's function annotations , extending type hints to all variables for comprehensive static analysis.
Reference: PEP 526 - Variable Annotations