Disclaimer
I wrote this guide at 11 PM after my launch file worked on my machine but not on the CI/CD pipeline. If the official ROS 2 documentation was clear, I wouldn’t need to write this. Here is what they should have told you from the start.
Introduction: Why This Guide Exists
The official ROS 2 launch documentation is like a phone book—technically complete, but not how you learn. This guide assumes:
- You’re tired of launch files that work differently on different machines
- You’ve been bitten by substitution evaluation order at least once
- You think
generate_launch_description()is the only function ROS 2 offers - You want to understand what’s actually happening instead of copy-pasting from Stack Overflow
This is Part 1 of a series covering every ROS 2 launch construct you’ll actually encounter, organized by what you need to know first.
The Fundamental Truth About ROS 2 Launch Files
Your launch file is not a configuration file.
It is a Python program that generates a configuration.
This changes everything.
Unlike ROS 1’s XML launch files, ROS 2 launch files are executable Python code. They run, generate a data structure, and pass it to the launch manager. This is both powerful and… dangerous.
Part 1: Core Launch Infrastructure
These are the foundation. Understand these first or everything else is magic.
1. LaunchDescription: The Container
LaunchDescription is the one thing every launch file must return. It’s a list-like container that holds everything your launch system will execute.
from launch import LaunchDescription
def generate_launch_description():
"""
Every. Single. Launch. File. Returns. This.
Think of it as: "Here's everything I want to start"
"""
return LaunchDescription([
# Actions go here
# Node 1
# Node 2
# Event handlers
# Conditions
# etc.
])Reality Check: LaunchDescription doesn’t execute anything. It just holds a list. The ROS 2 launch manager reads it and figures out what to do.
# ✅ This works
ld = LaunchDescription([node1, node2, event_handler])
return ld
# ✅ This also works
return LaunchDescription([node1, node2, event_handler])
# ✅ This works too (because it's just a Python list)
actions = [node1, node2, event_handler]
return LaunchDescription(actions)
# ❌ This doesn't work (forgot to return!)
LaunchDescription([node1, node2]) # Generates the description but doesn't use it
# ❌ This doesn't work (forgot LaunchDescription!)
return [node1, node2] # List, not LaunchDescription2. LaunchContext: The Shadowy Presence
LaunchContext is the object that gets passed around internally during launch execution. You’ll rarely create one directly, but you’ll use it indirectly in OpaqueFunction and event handlers.
Why care? It holds:
- Current launch configurations
- Environment variables
- Substitution evaluation state
- Local variables for the launch session
from launch import LaunchContext
from launch.actions import OpaqueFunction
def my_callback(context: LaunchContext):
"""
This runs during launch execution.
Context contains evaluated substitutions, configs, etc.
"""
# Get current launch configuration value
config_value = context.launch_configurations.get('my_param')
# Get environment variables
env_vars = context.environment
return [] # Return list of actions to execute next
my_opaque_function = OpaqueFunction(function=my_callback)Honest advice: You don’t need to understand LaunchContext deeply unless you’re doing something fancy with OpaqueFunction. For 90% of use cases, you can ignore it.
3. Actions: The Building Blocks
An “Action” in ROS 2 launch is anything that gets executed. There are roughly 30 of them, but you’ll use about 5-10 regularly.
Action Categories:
Actions
|- Node-based (execute ROS nodes)
| |- Node
| |- LifecycleNode
| |- LoadComposableNodes
| `- ComposableNodeContainer
|
|- Process-based (execute any program)
| `- ExecuteProcess
|
|- Configuration (change system state)
| |- DeclareLaunchArgument
| |- SetEnvironmentVariable
| |- AppendEnvironmentVariable
| |- UnsetEnvironmentVariable
| |- SetParameter
| |- SetParametersFromFile
| |- SetRemap
| |- SetLaunchConfiguration
| `- PushROSNamespace
|
|- Composition (group actions)
| |- GroupAction
| |- IncludeLaunchDescription
| `- OpaqueFunction
|
|- Events (react to lifecycle)
| |- RegisterEventHandler
| |- EmitEvent
| `- Shutdown
|
`- Utilities (logging, timing)
|- LogInfo
|- LogWarn
|- LogError
`- TimerAction
You don’t need to know all of them. Here’s what you actually need:
from launch import LaunchDescription, DeclareLaunchArgument, LaunchConfiguration
from launch_ros.actions import Node
from launch.actions import (
IncludeLaunchDescription,
ExecuteProcess,
GroupAction,
OpaqueFunction,
LogInfo,
TimerAction,
)
from launch.event_handlers import OnProcessStart
from launch.actions import RegisterEventHandler
def generate_launch_description():
"""These are the 80/20 actions you'll use everywhere"""
# 1. Declare arguments (configuration inputs)
my_arg = DeclareLaunchArgument('arg_name', default_value='default')
# 2. Create nodes (the main thing)
my_node = Node(package='pkg', executable='exe', name='node_name')
# 3. Include other launch files (composition)
other_launch = IncludeLaunchDescription(...)
# 4. Run arbitrary programs
other_program = ExecuteProcess(cmd=['python3', 'script.py'])
# 5. Group related actions
grouped = GroupAction(actions=[node1, node2])
# 6. Dynamic behavior (Python code during launch)
dynamic = OpaqueFunction(function=my_callback)
# 7. Log messages
log = LogInfo(msg='Hello from launch!')
# 8. React to events
handler = RegisterEventHandler(event_handler=OnProcessStart(...))
return LaunchDescription([
my_arg,
my_node,
other_launch,
other_program,
grouped,
dynamic,
log,
handler,
])Part 2: Configuration Actions Deep Dive
4. DeclareLaunchArgument: Making Your Launch File Configurable
This is how users pass configuration to your launch file. It’s critical.
from launch import DeclareLaunchArgument
# Basic form
arg = DeclareLaunchArgument(
'argument_name', # What users type
default_value='default', # Fallback value
description='What it does' # Help text
)
# With choices (restrict options)
robot_type = DeclareLaunchArgument(
'robot',
default_value='turtlebot',
choices=['turtlebot', 'husky', 'fetch'],
description='Robot platform'
)How users call it:
ros2 launch my_pkg my_launch.py # Uses defaults
ros2 launch my_pkg my_launch.py arg_name:=value # Override
ros2 launch my_pkg my_launch.py --show-args # See all argsWhy this matters: Every configurable value should be a DeclareLaunchArgument. This isn’t just good practice—it makes your launch file reusable across projects.
from launch import (
LaunchDescription,
DeclareLaunchArgument,
LaunchConfiguration,
)
from launch_ros.actions import Node
def generate_launch_description():
# ✅ Good: Values are configurable
robot_name = DeclareLaunchArgument(
'robot_name',
default_value='turtlebot'
)
robot_name_cfg = LaunchConfiguration('robot_name')
node = Node(
package='my_pkg',
executable='my_exe',
name='controller',
namespace=robot_name_cfg, # Now it's configurable!
)
# ❌ Bad: Hardcoded value
node_bad = Node(
package='my_pkg',
executable='my_exe',
name='controller',
namespace='turtlebot', # Hard to reuse
)
return LaunchDescription([robot_name, node])5. SetEnvironmentVariable (and Friends)
Modify the environment that child processes inherit.
from launch.actions import (
SetEnvironmentVariable,
AppendEnvironmentVariable,
UnsetEnvironmentVariable,
)
def generate_launch_description():
# Set ROS_LOG_LEVEL for all children
set_log = SetEnvironmentVariable('ROS_LOG_LEVEL', 'debug')
# Append to PATH (useful for finding executables)
append_path = AppendEnvironmentVariable(
'PATH',
'/opt/custom/bin'
)
# Remove a variable
unset_var = UnsetEnvironmentVariable('OLD_VARIABLE')
return LaunchDescription([
set_log,
append_path,
unset_var,
])Reality: Most of the time you won’t need these. But when debugging why a node can’t find a library? This is your friend.
6. SetParameter, SetParametersFromFile: Runtime Parameters
Set ROS 2 parameters before nodes start.
from launch.actions import SetParameter, SetParametersFromFile
from launch.substitutions import FindPackageShare, PathJoinSubstitution
def generate_launch_description():
# Method 1: Direct parameter
set_param = SetParameter(
name='my_param',
value=42
)
# Method 2: From YAML file
pkg_share = FindPackageShare('my_pkg')
params_file = PathJoinSubstitution([
pkg_share,
'config',
'params.yaml'
])
load_params = SetParametersFromFile(parameter_file=params_file)
return LaunchDescription([
set_param,
load_params,
])Important: These run before nodes start, so nodes see the parameters immediately.
7. SetRemap, PushROSNamespace: Network Topology
Control how topics and services are named without modifying node code.
from launch.actions import SetRemap, PushROSNamespace
from launch_ros.actions import Node
def generate_launch_description():
# Method 1: Remap at the node level
node_with_remap = Node(
package='sensor_driver',
executable='lidar_node',
remappings=[
('scan', '/robot/lidar/scan'), # Old name -> New name
]
)
# Method 2: Remap globally for subsequent nodes
global_remap = SetRemap(
src='/old/topic',
dst='/new/topic'
)
# Method 3: Add namespace to subsequent nodes
add_namespace = PushROSNamespace('robot1')
return LaunchDescription([
global_remap,
add_namespace,
node_with_remap,
])When to use: When integrating third-party nodes that assume different topic names, or when you need multiple instances of the same node with different namespaces.
Part 3: Composition & Execution Actions
8. IncludeLaunchDescription: Modular Launch Files
This is how you break large launch files into smaller, reusable pieces.
from launch import IncludeLaunchDescription, LaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import FindPackageShare, PathJoinSubstitution
def generate_launch_description():
pkg_share = FindPackageShare('my_robot_bringup')
# Include another Python launch file
drivers_launch = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
PathJoinSubstitution([
pkg_share,
'launch',
'drivers.py' # Another launch file in same package
])
),
launch_arguments={
'robot_name': 'turtlebot', # Pass args to the included file
}.items(),
)
# You can also include from external packages
nav_launch = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
PathJoinSubstitution([
FindPackageShare('nav2_bringup'),
'launch',
'bringup_launch.py'
])
),
launch_arguments={
'use_sim_time': 'true',
}.items(),
)
return LaunchDescription([
drivers_launch,
nav_launch,
])Structure Example:
my_robot_bringup/launch/
├── robot_bringup.py # Main file (includes others)
├── hardware_drivers.py # Hardware-specific
├── perception.py # Vision nodes
├── navigation.py # Nav stack
└── simulation.py # Gazebo
Pro tip: Use IncludeLaunchDescription to keep files under 100 lines. Your future self will thank you.
9. ExecuteProcess: Running Arbitrary Programs
Launch any executable, not just ROS nodes.
from launch.actions import ExecuteProcess
def generate_launch_description():
# Run a Python script
python_script = ExecuteProcess(
cmd=['python3', '/path/to/script.py', 'arg1', 'arg2'],
output='screen', # Show output
)
# Run a shell command
shell_cmd = ExecuteProcess(
cmd=['bash', '-c', 'echo "Robot starting..."'],
output='screen',
)
# Run with environment variables
with_env = ExecuteProcess(
cmd=['./my_binary'],
env={
'MY_VAR': 'value',
'PATH': '/custom/bin:$PATH',
},
output='screen',
)
return LaunchDescription([
python_script,
shell_cmd,
with_env,
])Honest truth: If you find yourself using ExecuteProcess frequently, you might want to wrap your code in a ROS 2 node instead. But for one-off scripts or third-party tools, it’s perfect.
10. GroupAction: Organize Related Actions
Group related actions together for clarity and scoping.
from launch.actions import GroupAction
from launch_ros.actions import Node, PushROSNamespace
def generate_launch_description():
# Group all robot1 nodes together
robot1_group = GroupAction(
actions=[
PushROSNamespace('robot1'),
Node(package='driver', executable='motor_node', name='motors'),
Node(package='driver', executable='lidar_node', name='lidar'),
Node(package='control', executable='controller', name='control'),
]
)
# Group all robot2 nodes together
robot2_group = GroupAction(
actions=[
PushROSNamespace('robot2'),
Node(package='driver', executable='motor_node', name='motors'),
Node(package='driver', executable='lidar_node', name='lidar'),
Node(package='control', executable='controller', name='control'),
]
)
return LaunchDescription([
robot1_group,
robot2_group,
])Visual clarity:
robot1_group
|- /robot1/motors
|- /robot1/lidar
`- /robot1/control
robot2_group
|- /robot2/motors
|- /robot2/lidar
`- /robot2/control
11. OpaqueFunction: “I Need to Run Python Code”
When you need to execute arbitrary Python during launch to make decisions.
from launch import LaunchDescription, LaunchContext
from launch.actions import OpaqueFunction
from launch_ros.actions import Node
def my_launch_function(context: LaunchContext):
"""
This Python function runs during launch execution.
The 'context' contains evaluated substitutions, configs, etc.
"""
# Access current launch configurations
some_param = context.launch_configurations.get('my_param', 'default')
# Do arbitrary Python logic
if some_param == 'special':
return [
Node(package='pkg', executable='special_node'),
]
else:
return [
Node(package='pkg', executable='normal_node'),
]
def generate_launch_description():
my_function = OpaqueFunction(function=my_launch_function)
return LaunchDescription([
my_function,
])When to use:
- Conditional node creation based on parameters
- Compute values based on host configuration
- Read system state and decide what to launch
When NOT to use:
- Anything you could do with
IfCondition(use that instead—it’s clearer)
12. TimerAction: Delayed Execution
Start something after N seconds.
from launch.actions import TimerAction
from launch_ros.actions import Node
def generate_launch_description():
# Launch main node immediately
main_node = Node(package='pkg', executable='main')
# Launch secondary node after 3 seconds
delayed_node = TimerAction(
period=3.0, # Seconds
actions=[
Node(package='pkg', executable='secondary')
]
)
return LaunchDescription([
main_node,
delayed_node,
])Use case: Give the main node time to initialize before launching dependent nodes.
Summary: Part 1
You now understand:
- LaunchDescription - The container that holds everything
- LaunchContext - The background object holding state
- Actions - The individual units of execution
- DeclareLaunchArgument - Making launch files configurable
- SetEnvironmentVariable - Controlling environment
- SetParameter - Setting ROS parameters
- IncludeLaunchDescription - Modular launch files
- ExecuteProcess - Running arbitrary programs
- GroupAction - Organizing actions
- OpaqueFunction - Running Python code
- TimerAction - Delayed execution
These are the core infrastructure. In Part 2, we will cover the ROS-specific actions (Node, LifecycleNode, etc.) and in Part 3, the powerful patterns (Substitutions, Conditions, Event Handlers).
Quick Cheat Sheet
from launch import (
LaunchDescription,
LaunchConfiguration,
DeclareLaunchArgument,
IncludeLaunchDescription,
)
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import FindPackageShare, PathJoinSubstitution
from launch_ros.actions import Node
from launch.actions import (
GroupAction,
OpaqueFunction,
TimerAction,
ExecuteProcess,
LogInfo,
)
def generate_launch_description():
"""Minimal complete example"""
# Argument
arg = DeclareLaunchArgument('param', default_value='val')
arg_cfg = LaunchConfiguration('param')
# Node
node = Node(
package='pkg',
executable='exe',
name='node_name',
namespace=arg_cfg,
)
# Include another launch
pkg = FindPackageShare('another_pkg')
other = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
PathJoinSubstitution([pkg, 'launch', 'other.py'])
)
)
return LaunchDescription([arg, node, other])