Vaibhav Shende Vaibhav Shende

ROS 2 Launch Files: A Frustrated Roboticist's Complete Guide - Part 1: Core Concepts

An opinionated, comprehensive guide to ROS 2 Python launch files. Written by someone who spent 3 days debugging a launch file that worked differently on different machines.

ROS 2

ROS 2 Launch Files: A Frustrated Roboticist's Complete Guide - Part 1: Core Concepts

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:

  1. You’re tired of launch files that work differently on different machines
  2. You’ve been bitten by substitution evaluation order at least once
  3. You think generate_launch_description() is the only function ROS 2 offers
  4. 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 LaunchDescription

2. 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 args

Why 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.


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:

  1. LaunchDescription - The container that holds everything
  2. LaunchContext - The background object holding state
  3. Actions - The individual units of execution
  4. DeclareLaunchArgument - Making launch files configurable
  5. SetEnvironmentVariable - Controlling environment
  6. SetParameter - Setting ROS parameters
  7. IncludeLaunchDescription - Modular launch files
  8. ExecuteProcess - Running arbitrary programs
  9. GroupAction - Organizing actions
  10. OpaqueFunction - Running Python code
  11. 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])

Next: Part 2: ROS 2 Specific Actions & Advanced Nodes →