- In the Dagster repository we bias towards functional-style programming with lightweight immutable objects.
Instead of modifying objects, we copy them and replace specific attributes (e.g. with
replaceindagster_shared.record). "Result" objects should be @record-annotated classes. - You do not need to create imports into
__init__.pyfiles unless 1) explicitly asked or 2) if this is a public API annotated with @public. For internal classes and functions we rely on absolute imports. - Do not use
printin production code. For CLI output, useclick.echo()for proper output handling and testing compatibility.
- Target Python Version: Python 3.9 and above (maintaining compatibility with older versions)
- Modern Python Features: Use Python 3.9+ features where appropriate
- Type Annotations: Use built-in generic types (e.g.,
list[str],dict[str, Any]) - available in Python 3.9+
- Type hints required for all Python code
- ALWAYS use builtin types for annotations:
dict,list,set,tupleinstead oftyping.Dict,typing.List,typing.Set,typing.Tuple - NEVER import or use
typing.Dict,typing.List,typing.Set,typing.Tuple- use built-in types instead (available in Python 3.9+) - All Python code must pass
pyrighttype checking with zero errors
-
ALWAYS use top-level (module-scoped) imports - avoid function-scoped imports except in specific cases
-
Acceptable exceptions for function-scoped imports:
- TYPE_CHECKING blocks: Imports only needed for type annotations
- Circular import resolution: When imports would create circular dependencies
- Optional dependencies: When import failure should be handled gracefully
- Expensive lazy loading: When imports are computationally expensive and conditionally used
- Performance-critical lazy imports: For modules that significantly slow down CLI startup time
-
Examples of correct import patterns:
# ✅ GOOD: Top-level imports
from contextlib import contextmanager
from dagster._core.definitions import JobDefinition
from dagster._utils.error import DagsterError
@contextmanager
def my_function():
job = JobDefinition(...)
# Use imports directly# ❌ BAD: Function-scoped imports without justification
@contextmanager
def my_function():
from dagster._core.definitions import JobDefinition
from dagster._utils.error import DagsterError
job = JobDefinition(...)# ✅ ACCEPTABLE: TYPE_CHECKING imports
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from dagster import JobDefinition
from dagster._core.storage.dagster_run import DagsterRun# ✅ ACCEPTABLE: Avoiding circular imports
def create_job():
# Import here to avoid circular dependency
from dagster._core.definitions import JobDefinition
return JobDefinition(...)# ✅ ACCEPTABLE: Performance-critical lazy imports for slow libraries
@click.command()
def deploy_command():
# Lazy import to avoid slowing CLI startup
from dagster_cloud_cli.commands.ci import deploy_impl
from dagster_cloud_cli.types import SnapshotBaseDeploymentCondition
# ... use importsThe following libraries are known to significantly slow down import time and MUST be lazy-loaded using function-scoped imports when used in CLI commands:
jinja2- Template engine with heavy initializationrequests- HTTP library with certificate loadingdagster_cloud_cli.*- Cloud CLI modules with complex dependenciesurllib.request- Built-in HTTP client with TLS setupyaml- YAML parsing with C extensionstyper- CLI framework with rich dependenciespydantic- Data validation with compiled validators
Example of correct lazy import pattern:
# ✅ GOOD: Lazy import of performance-critical libraries
@click.command()
def my_command():
"""Command that uses expensive libraries."""
# Lazy import to avoid loading at CLI startup
import yaml
import requests
from dagster_cloud_cli.commands.ci import deploy_impl
# Use the imports in function body
config = yaml.safe_load(config_file)
response = requests.get(api_url)- ALWAYS use
@recordfor dataclasses, lightweight objects, and immutable objects in Dagster codebase except in specific circumstances- Import:
from dagster_shared.record import record - DEFAULT CHOICE: When creating any new class that holds data, use
@recordunless you have a specific reason not to - Use
@recordfor:- Data transfer objects (DTOs) and result objects
- Configuration objects
- Error/mismatch reporting objects
- Analysis results and comparison objects
- Any immutable data structure
- Lightweight objects that hold related data
- Classes that represent findings, issues, or validation results
- ONLY use
@dataclasswhen:- Mutability is required
- Working with external libraries that expect dataclasses
- Import:
- Use
with_*naming for methods that return new instances with additional data- ✅
result.with_error("message")- Returns new ValidationResult with added error - ✅
result.with_warning("message")- Returns new ValidationResult with added warning - ✅
result.with_parsing_failed()- Returns new ValidationResult with failed parsing state - ❌
result.add_error("message")- Confusing, sounds like it mutates the object - ❌
result.append_warning("message")- Implies mutation rather than returning new instance
- ✅
- Rationale: The
with_*convention makes it clear that:- The method returns a new instance (immutable pattern)
- The original object is not modified
- The new instance includes the specified addition/change
- NEVER use global singleton objects for services or stateful components
- ALWAYS pass service instances explicitly as parameters
- Rationale: Global singletons create hidden dependencies, make testing difficult, and violate dependency injection principles
❌ Bad - Global singleton pattern:
_service: Optional[MyService] = None
def get_service() -> Optional[MyService]:
return _service
def initialize_service():
global _service
_service = MyService()
def some_function():
service = get_service() # Hidden dependency✅ Good - Instance-based pattern:
def create_service() -> MyService:
return MyService()
def some_function(service: MyService): # Explicit dependency
service.do_something()- ALWAYS use
Literaltypes for string constants that represent enums - ALWAYS create type aliases for reusable literal types
✅ Good:
from typing import Literal
DiagnosticsLevel = Literal["off", "error", "info", "debug"]
def create_service(level: DiagnosticsLevel = "off") -> MyService:
return MyService(level=level)❌ Bad:
def create_service(level: str = "off") -> MyService: # No type safety
return MyService(level=level)- AVOID using try/except blocks for basic control flow when types provide the information
- USE direct logic with type-safe operations
❌ Bad - Using try/except for basic logic:
try:
current_index = level_hierarchy.index(self.level)
entry_index = level_hierarchy.index(entry_level)
return entry_index <= current_index
except ValueError:
return False✅ Good - Direct logic with dictionaries:
level_priority = {"error": 0, "info": 1, "debug": 2}
current_priority = level_priority.get(self.level)
entry_priority = level_priority.get(entry_level)
if current_priority is None or entry_priority is None:
return False
return entry_priority <= current_priorityDO NOT assign unentered context manager objects to intermediate variables - use them directly as the target of with:
# BAD: Assigning context manager to variable before entering
run_context = build_run_status_sensor_context(
sensor_name="test_sensor",
dagster_run=dagster_run,
)
with run_context:
# work with run_context
# GOOD: Use context manager directly in with statement
with build_run_status_sensor_context(
sensor_name="test_sensor",
dagster_run=dagster_run,
) as run_context:
# work with run_contextRationale: Assigning an unentered context manager to a variable can lead to resource leaks if the variable is accidentally used outside the context manager, and makes the code less clear about when resources are acquired and released.
Exception: When you need to access properties of the context manager object after it exits (e.g., results set during __exit__), it's acceptable to assign to a variable:
# ACCEPTABLE: When you need post-exit access to context manager properties
run_context = build_run_status_sensor_context(sensor_name, dagster_run)
with run_context:
# do work within context
pass
# Access properties set during __exit__
return SomeResult(run_id=run_context.run_id)ALWAYS prefer Dagster's check methods over raw assertions or manual validation when validating types, nullness, and other conditions:
import dagster._check as check
# ✅ GOOD: Using check methods
result = check.not_none(optional_value)
validated_list = check.list_param(items, "items")
valid_str = check.str_param(name, "name")
# ❌ BAD: Using raw assertions
assert optional_value is not None
result = optional_valueBenefits of check methods:
- Cannot be disabled: Unlike
assertstatements, check methods cannot be disabled withpython -Ooptimization - Better error messages: Provide clear, contextual error messages with parameter names
- Type safety: Help type checkers understand the validated types
- Consistent API: Uniform validation interface across the codebase
- Runtime safety: Always execute validation regardless of Python optimization settings
Common check methods:
check.not_none(value)- Validates non-null values and returns typed resultcheck.str_param(value, "param_name")- Validates string parameterscheck.list_param(value, "param_name")- Validates list parameterscheck.dict_param(value, "param_name")- Validates dictionary parameterscheck.bool_param(value, "param_name")- Validates boolean parameterscheck.opt_str_param(value, "param_name")- Validates optional string parameters
This codebase follows specific norms for exception handling to maintain clean, predictable code:
- By default, exceptions should NOT be used as control flow
- Do NOT implement "fallback" behavior in catch blocks - exceptions should bubble up the stack to be handled at appropriate boundaries
- Avoid catching broad
Exceptiontypes unless you have a specific reason
- Error Boundaries: Meaningful divisions in software that have sensible default error behavior
- CLI commands (top-level exception handlers for user-friendly error messages)
- Asset materialization operations (individual asset failures shouldn't fail entire job)
- API Compatibility: Compensating for APIs that use exceptions for control flow
- When third-party APIs use exceptions to indicate missing keys/values
- When storage systems have different capabilities that can't be detected a priori
- Embellishing Exceptions: Adding context to in-flight exceptions before re-raising
When violating exception norms is necessary, encapsulate the violation within a function:
# GOOD: Exception handling encapsulated in helper function
def _get_asset_value_with_fallback(context, asset_key, default_value):
"""
Try to get asset value, fallback to default.
Some storage systems may not support certain operations,
so we use exception handling to detect this case.
"""
try:
return context.instance.get_latest_materialization_event(asset_key).asset_materialization.metadata
except Exception:
return default_value
# BAD: Exception control flow exposed in main logic
try:
metadata = context.instance.get_latest_materialization_event(asset_key).asset_materialization.metadata
except Exception:
metadata = default_valueWhen possible, check conditions that cause errors before making calls:
# PREFERRED: Check condition beforehand
if context.instance.has_asset_key(asset_key):
return get_asset_metadata(asset_key)
else:
return get_default_metadata()
# AVOID: Using exceptions to discover the condition
try:
return get_asset_metadata(asset_key) # Will fail if key doesn't exist
except KeyError:
return get_default_metadata()ALWAYS use membership testing (in) before accessing dictionary keys instead of catching KeyError:
# PREFERRED: Proactive key existence checking
if asset_key in asset_metadata:
value = asset_metadata[asset_key]
# process value
else:
# handle missing key case
handle_missing_asset()
# AVOID: Using KeyError as control flow
try:
value = asset_metadata[asset_key]
# process value
except KeyError:
handle_missing_asset()Rationale: Membership testing is more explicit about intent, performs better, and avoids using exceptions for control flow. The in operator clearly indicates that you're checking for key existence before access.
DO NOT catch exceptions just to re-raise them with different messages unless you're adding meaningful context:
# BAD: Unnecessary exception transformation
try:
validate_asset_config(config)
except ValidationError as e:
raise DagsterConfigError(f"Invalid asset config: {e}")
# GOOD: Let the original exception bubble up with its specific error details
validate_asset_config(config)
# ACCEPTABLE: Adding meaningful context before re-raising
try:
validate_asset_config(config)
except ValidationError as e:
raise DagsterConfigError(f"Asset '{asset_name}' has invalid configuration: {e}") from eRationale: The original exception often contains more precise error information than generic wrapper messages. Only transform exceptions when you're adding valuable context that helps with debugging or user experience.
NEVER swallow exceptions silently - always let them bubble up to appropriate error boundaries:
# BAD: Silently swallowing exceptions
try:
if not asset_exists(asset_key):
return
for partition in get_partitions(asset_key):
if should_process_partition(partition):
process_partition(partition)
except (AssetNotFoundError, PartitionError):
return # Silently fails, hiding real problems
# GOOD: Let exceptions bubble up
if not asset_exists(asset_key):
return
for partition in get_partitions(asset_key):
if should_process_partition(partition):
process_partition(partition)NEVER implement fallback behavior in exception handlers unless you're at an appropriate error boundary:
# BAD: Using exceptions for fallback logic
try:
return parse_asset_key_from_string(key_str)
except ValueError:
# Fallback to manual parsing
return AssetKey([key_str])
# GOOD: Let the original exception bubble up
return parse_asset_key_from_string(key_str)Rationale: Exception swallowing masks real problems and makes debugging extremely difficult. If an exception occurs, it usually indicates a genuine issue that needs to be addressed, not hidden.
- ALWAYS use
clickfor building CLI tools - it's the standard CLI framework in Dagster - Use
click.echo()instead ofprint()for all CLI output- Ensures proper output handling in different environments
- Works correctly with output redirection and testing
- Handles Unicode properly across platforms
- Example:
# ✅ GOOD: Using click.echo for CLI output
import click
@click.command()
def my_command():
click.echo("Processing started...")
click.echo(f"Found {count} items")
# ❌ BAD: Using print in CLI code
@click.command()
def my_command():
print("Processing started...") # Don't use print
print(f"Found {count} items")- Code must be compatible with Python 3.10 and later
- Use Python 3.10+ features where appropriate (built-in generics like
list[str],dict[str, Any], etc.)
- Follow Google-style docstrings for all public functions and classes
- Include Args, Returns, and Raises sections where applicable
- Add examples in docstrings for complex functionality
- NEVER use
__all__in subpackage__init__.pyfiles - Only use
__all__in top-level package__init__.pyfiles to define public APIs - For re-exporting symbols in
__init__.pyfiles: Use the explicit import patternimport foo as fooinstead of relying on__all__- This makes re-exports explicit and avoids the pitfalls of
__all__management - Example:
from dagster.submodule import SomeClass as SomeClass
- This makes re-exports explicit and avoids the pitfalls of
- Rely on absolute imports for internal classes and functions