ROS 2 LaunchConfiguration value as int: why ‘2’+’5′ gives 25
What happened when I ran it
You declare two launch arguments, a with default 2 and b with default 5, and somewhere in the launch file you want their sum. Calling int(LaunchConfiguration('a')) inside generate_launch_description() raises a TypeError. So you try what launch files seem to want: put both in a list, [a, b]. That doesn’t crash. It also doesn’t give you 7.
I ran this on ROS 2 Jazzy in the official ros:jazzy-ros-base container, five times. Each run did two things: it called the launch API directly, and it ran a real ros2 launch with a small rclpy node that prints back the parameters it received. The list [a, b] became the string '25'. Passed as a node parameter, it reached the node as the integer 25, ROS type INTEGER, with no warning. With a:=3 b:=4 on the command line it became 34. PythonExpression([a, b]) also gave 25. Only PythonExpression([a, ' + ', b]), and an OpaqueFunction that converted the values with int(), produced 7. Every result matched the predictions I wrote down before the runs, and all five runs gave identical output.
The short answer
A LaunchConfiguration is not a value. It’s a substitution: a placeholder that gets looked up and turned into a string while the launch description is executing, which is after generate_launch_description() has already returned. When you put substitutions and strings in a list, launch joins them with ''.join(...). Nothing on that path adds numbers.
So decide where the computation should happen:
- Arithmetic:
PythonExpression([a, ' + ', b]). The operator has to be one of the list pieces. The result is still a string ('7'). - Conditions:
IfCondition(PythonExpression([r, ' == 200'])).IfConditionon its own only acceptstrue,1,falseand0. - Real Python values (
int(), loops, reading files, building several nodes): use anOpaqueFunctionand callLaunchConfiguration('a').perform(context)inside it. - A node parameter that must stay a string:
ParameterValue([a, b], value_type=str).
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, LogInfo, OpaqueFunction
from launch.substitutions import LaunchConfiguration, PythonExpression
def add_args(context):
# perform() returns a str, so convert it yourself
a = int(LaunchConfiguration('a').perform(context))
b = int(LaunchConfiguration('b').perform(context))
return [LogInfo(msg=f'opaque sum = {a + b}')] # return a list (or None)
def generate_launch_description():
a, b = LaunchConfiguration('a'), LaunchConfiguration('b')
return LaunchDescription([
DeclareLaunchArgument('a', default_value='2'),
DeclareLaunchArgument('b', default_value='5'),
LogInfo(msg=['joined = ', a, b]), # '25'
LogInfo(msg=['sum = ', PythonExpression([a, ' + ', b])]), # '7'
OpaqueFunction(function=add_args), # 7
])
Measured results
Environment. Image ros:jazzy-ros-base (ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW rmw_fastrtps_cpp (default), no network, limits 1 CPU and 1 GiB. The script experiment.py (sha256 cea07ed70758bc1cf90451961436f50180a14253aac301f862ac92003aee3bc3) ran 5 times. All 5 runs exited with code 0 and took 2.77 s to 2.8 s. The output of every case was identical across the 5 runs, apart from the memory addresses inside object representations, shown below as 0x….
Setup. Part A built a LaunchContext with a='2', b='5', idx='2', r='200', u='True', name='robot1' and called perform, perform_substitutions, IfCondition.evaluate and launch_ros.utilities.evaluate_parameters (after normalize_parameters). Part B wrote a launch file and a probe node to /tmp and ran ros2 launch twice, once with defaults and once with a:=3 b:=4 r:=199, with a 25 s timeout each. The probe node was started as python3 /tmp/param_probe.py with automatically_declare_parameters_from_overrides=True and printed each parameter’s value, ROS type and Python type. Predictions come from plan.md, written before the runs.
Part A: launch API calls
| Code | Predicted (plan.md) | Measured, all 5 runs |
|---|---|---|
str(LaunchConfiguration('a')) |
object representation, not '2' |
'<launch.substitutions.launch_configuration.LaunchConfiguration object at 0x…>' |
int(LaunchConfiguration('a')) |
TypeError |
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'LaunchConfiguration' |
LaunchConfiguration('a') + LaunchConfiguration('b') |
TypeError |
TypeError: unsupported operand type(s) for +: 'LaunchConfiguration' and 'LaunchConfiguration' |
LaunchConfiguration('a').perform(LaunchContext()) (empty context) |
SubstitutionFailure |
SubstitutionFailure: launch configuration 'a' does not exist |
a.perform(ctx) |
'2', str |
'2', str |
perform_substitutions(ctx, [a, b]) |
'25', str |
'25', str |
perform_substitutions(ctx, ['robot_', a, b]) |
'robot_25' |
'robot_25', str |
PythonExpression([a, b]) |
'25', str |
'25', str |
PythonExpression([a, ' + ', b]) |
'7', str |
'7', str |
PythonExpression([idx, ' * 1.5']) |
'3.0', str |
'3.0', str |
PythonExpression([name, " == 'robot1'"]) |
NameError |
NameError: name 'robot1' is not defined |
PythonExpression(["'", name, "' == 'robot1'"]) |
'True' |
'True', str |
IfCondition(PythonExpression([r, ' == 200', ' and ', u])), r=’200′ |
True |
True |
| same condition, r=’199′ | False |
False |
IfCondition(LaunchConfiguration('r')), r=’200′ |
InvalidConditionExpressionError |
InvalidConditionExpressionError: invalid condition expression, expected one of [true, 1, false, 0] but got '200', expanded from '<launch.substitutions.launch_configuration.LaunchConfiguration object at 0x…>' |
| same, r=’1′ / r=’ TRUE ‘ / r=’yes’ | True / True / InvalidConditionExpressionError |
True / True / InvalidConditionExpressionError: … but got 'yes', … |
parameters=[{'offset': [a, b]}] |
25, int |
{'offset': (25, 'int')} |
parameters=[{'offset': PythonExpression([a, ' + ', b])}] |
7, int |
{'offset': (7, 'int')} |
parameters=[{'scale': PythonExpression([idx, ' * 1.5'])}] |
3.0, float |
{'scale': (3.0, 'float')} |
parameters=[{'offset': ParameterValue([a, b], value_type=str)}] |
'25', str |
{'offset': ('25', 'str')} |
Part B: real ros2 launch
| Output | Defaults: predicted | Defaults: measured | a:=3 b:=4 r:=199: predicted |
a:=3 b:=4 r:=199: measured |
|---|---|---|---|---|
LogInfo(msg=['OUT concat=', a, b]) |
25 | 25 | 34 | 34 |
PythonExpression([a, b]) |
25 | 25 | 34 | 34 |
PythonExpression([a, ' + ', b]) |
7 | 7 | 7 | 7 |
OpaqueFunction: types from perform(context) |
str,str | str,str | str,str | str,str |
OpaqueFunction: int(a) + int(b) |
7 | 7 | 7 | 7 |
LogInfo with IfCondition(PythonExpression([r, ' == 200', ' and ', u])) |
runs | ran | skipped | skipped (no line) |
node param offset_concat = [a, b] |
25 (INTEGER) | 25, INTEGER, int | 34 (INTEGER) | 34, INTEGER, int |
node param offset_sum = PythonExpression([a, ' + ', b]) |
7 (INTEGER) | 7, INTEGER, int | 7 (INTEGER) | 7, INTEGER, int |
node param scale = PythonExpression([idx, ' * 1.5']) |
3.0 (DOUBLE) | 3.0, DOUBLE, float | 3.0 (DOUBLE) | 3.0, DOUBLE, float |
node param offset_str = ParameterValue([a, b], value_type=str) |
’25’ (STRING) | ’25’, STRING, str | ’34’ (STRING) | ’34’, STRING, str |
printed inside generate_launch_description() |
LaunchConfiguration, not str | type=LaunchConfiguration is_str=False |
same | type=LaunchConfiguration is_str=False |
Both ros2 launch calls exited with code 0 in every run. The defaults run took 0.89 s to 0.902 s and the override run 0.77 s to 0.785 s.
Raw log lines from run 1, ros2 launch /tmp/args_demo.launch.py (defaults):
[INFO] [launch.user]: OUT concat=25
[INFO] [launch.user]: OUT pyexpr_no_operator=25
[INFO] [launch.user]: OUT pyexpr_sum=7
[INFO] [launch.user]: OUT opaque_types=str,str
[INFO] [launch.user]: OUT opaque_sum=7
[INFO] [launch.user]: OUT if_branch=ran
[python3-1] PROBE {"offset_concat": {"value": 25, "ros_type": "INTEGER", "py_type": "int"}, "offset_sum": {"value": 7, "ros_type": "INTEGER", "py_type": "int"}, "scale": {"value": 3.0, "ros_type": "DOUBLE", "py_type": "float"}, "offset_str": {"value": "25", "ros_type": "STRING", "py_type": "str"}}
And with a:=3 b:=4 r:=199:
[INFO] [launch.user]: OUT concat=34
[INFO] [launch.user]: OUT pyexpr_no_operator=34
[INFO] [launch.user]: OUT pyexpr_sum=7
[INFO] [launch.user]: OUT opaque_types=str,str
[INFO] [launch.user]: OUT opaque_sum=7
[python3-1] PROBE {"offset_concat": {"value": 34, "ros_type": "INTEGER", "py_type": "int"}, "offset_sum": {"value": 7, "ros_type": "INTEGER", "py_type": "int"}, "scale": {"value": 3.0, "ros_type": "DOUBLE", "py_type": "float"}, "offset_str": {"value": "34", "ros_type": "STRING", "py_type": "str"}}
Why you can’t read the value in generate_launch_description()
A Python launch file runs in two phases. First, generate_launch_description() runs once and builds a plan: a list of actions. Then the launch service executes that plan. Command-line arguments such as a:=2 only land in the launch context’s launch_configurations dictionary during the second phase, when the DeclareLaunchArgument action executes. The ROS 2 docs define substitutions as variables that are “only evaluated during execution of the launch description”.
That explains the first three rows of Part A. While your function is building the plan, LaunchConfiguration('a') is just an object. It isn’t a string (the launch file printed is_str=False), it has no numeric conversion and no + operator, so print() shows an object representation, and int() and + raise TypeError. In the execution phase, perform(context) looks the name up and returns a str. If the name was never set and has no default, it raises SubstitutionFailure: launch configuration 'a' does not exist.
Where a launch API takes a “substitution or list of substitutions”, launch normalizes the list and joins the performed pieces. In the Jazzy source that is literally one line: ''.join([context.perform_substitution(sub) for sub in subs]). The launch architecture docs say the same thing in prose: an iterable of substitutions is concatenated into a single string. The Rolling tutorial has a tip saying so too. The Jazzy version of the tutorial doesn’t include that tip, which may be why the behavior surprises people.
Following ‘2’ and ‘5’ down three paths
1. A plain list: string concatenation
[a, b] with '2' and '5' becomes '25'. This is exactly what you want for topic names, frame prefixes and file paths: ['robot_', a], or the official tutorial’s [LaunchConfiguration('file'), '_launch', '.py']. It’s only a trap when the pieces look like numbers. Read as a decimal number, '25' is 2 × 10 + 5 = 25, which is 18 more than the 7 you meant. With a:=3 b:=4 it’s 34 instead of 7.
2. PythonExpression: join first, then eval
PythonExpression doesn’t do arithmetic on its pieces. It joins the list into one string with the same perform_substitutions, passes that string to Python’s eval, and returns str() of the result. So:
PythonExpression([a, b])evaluates the source text25and gives'25'. Wrapping the list inPythonExpressiondoesn’t help if the operator is missing.PythonExpression([a, ' + ', b])evaluates2 + 5and gives'7'.PythonExpression([idx, ' * 1.5'])evaluates2 * 1.5and gives'3.0'. Names from themathmodule can be used without a prefix.
Because the joined string is Python source, string arguments need quotes around them. PythonExpression([name, " == 'robot1'"]) becomes robot1 == 'robot1', and Python looks for a variable called robot1, which is the NameError in the table. Write PythonExpression(["'", name, "' == 'robot1'"]) instead. Keep in mind what this means: whatever someone passes on the command line becomes part of a Python expression that gets evaluated. I haven’t found an official statement on the security implications, so I’ll only state the mechanism.
3. Node parameters: the joined string is parsed as YAML
This is the path that fails silently. When a value in Node(parameters=[{...}]) is a substitution or a list of them, launch_ros joins it into a string and then runs it through yaml.safe_load. If the result is a bool, int, float or str, that’s the type the parameter gets. '25' is an integer in YAML, so the node receives the INTEGER 25. No error is raised, and nothing in the log looks wrong. The same rule turned '7' into an INTEGER and '3.0' into a DOUBLE, which is what you want when the expression itself is correct.
If a parameter must stay a string (a serial number, an ID with leading zeros, a version like 1.10), pin the type with ParameterValue(..., value_type=str) from launch_ros.parameter_descriptions. In the test, that kept '25' as STRING.
Conditions: IfCondition doesn’t use Python truthiness
IfCondition and UnlessCondition take the performed string, strip it, lowercase it, and accept exactly four values: true, 1, false, 0. That’s why ' TRUE ' passed, and why '200' and 'yes' raised:
InvalidConditionExpressionError: invalid condition expression, expected one of [true, 1, false, 0] but got '200'
An empty string isn’t treated as false and a nonzero number isn’t treated as true. To branch on a number, let PythonExpression compute a comparison. It returns 'True' or 'False', which IfCondition accepts after lowercasing. This is the pattern from the official tutorial:
LogInfo(
msg='red background requested',
condition=IfCondition(PythonExpression([r, ' == 200', ' and ', use_red])),
)
OpaqueFunction: when you need real Python values
If you need the value as a Python int or str (to loop over a count, read a file whose name came from an argument, or pick between configurations with ordinary if statements), move that code into an OpaqueFunction. Launch calls your function during execution as function(context, *args, **kwargs), so perform(context) works there and returns a real string. Convert it yourself and return the actions you want to add:
from launch.actions import OpaqueFunction
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def launch_setup(context):
count = int(LaunchConfiguration('count').perform(context))
return [
Node(package='demo_nodes_py', executable='talker', name=f'talker_{i}')
for i in range(count)
]
# in generate_launch_description():
# DeclareLaunchArgument('count', default_value='2'),
# OpaqueFunction(function=launch_setup),
Return a list of actions, or None. One accepted answer on Robotics Stack Exchange returns a single Node and uses a variable its function never defines, so it doesn’t run as written. In the test, the values from perform(context) were str,str, and int(a) + int(b) gave 7 for both the defaults and the overrides.
Some answers suggest parsing sys.argv yourself. That depends on argument positions and isn’t part of the launch API, so prefer OpaqueFunction.
Which tool for which job
| You want to | Use | What you get |
|---|---|---|
| Build a name, namespace or path from arguments | A plain list: ['robot_', a] |
The joined string |
| Compute a number for a parameter or another argument | PythonExpression([a, ' + ', b]), operator included |
A string like '7'; becomes an int or float as a node parameter |
| Start an action only in some cases | IfCondition(PythonExpression([...comparison...])) |
'True' or 'False', which IfCondition accepts |
| Use general Python logic on argument values | OpaqueFunction + perform(context) |
Real str values you convert yourself |
| Keep a node parameter as a string | ParameterValue(..., value_type=str) |
A STRING parameter, even for '25' |
Before you trust a numeric parameter on a robot
The dangerous version of this bug doesn’t crash. If an offset, a speed limit or a gain is built from launch arguments with a plain list, the node starts normally with a different number. After launching, check what the node actually received with ros2 param get <node> <parameter> and its type with ros2 param describe. On hardware, do that before the robot moves, start slowly, and keep an emergency stop within reach. That’s general advice, not a substitute for your robot’s own safety procedure.
One more lesson came from the trial runs before the five measured ones. When the probe node couldn’t start at all (the container’s /tmp was mounted without exec permission, so the script got Permission denied), ros2 launch still exited with code 0. An exit code of 0 doesn’t tell you that a node started or got its parameters. That’s why the test only counts parameter results that the node printed itself.
Limits of this test
- ROS 2 Jazzy only. The container had
ros-jazzy-launch3.4.11 andros-jazzy-launch-ros0.26.12. The source links below point at newer Jazzy branch commits (3.4.12 and 0.26.13), but the seven source files I relied on are byte-identical to the installed versions. I didn’t compare Humble, Kilted or Rolling behavior. - Python launch files only. XML and YAML launch files use the same substitutions (
$(var),$(eval)), but I didn’t test them. - The override pair
a:=3 b:=4also adds up to 7, so thepyexpr_sumandopaque_sumlines alone can’t show that the sum followed the command line. The concatenated values (34) and the parameteroffset_str('34') show that the overrides arrived. A pair with a different sum would have been a stronger test. - The probe was a minimal rclpy node with
automatically_declare_parameters_from_overrides=True. A node that declares its parameters with explicit types may reject a mismatched type instead of accepting it silently. That wasn’t tested. - No simulator or robot was involved. Everything here is about how launch turns strings into values.
Sources and reproduction
The full reproduction is at github.com/sungpyo9053/blog/…/ros2-python-launch-argument-value-substitution-string-concat. It contains experiment.py (including the generated launch file and probe node), the predictions and trial-run notes in plan.md (in Korean), and the raw output of all five runs in results.json. To approximate the test container, run this from the directory that contains experiment.py:
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work:ro --tmpfs /tmp \
ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
A run takes a few seconds and prints one JSON line per case. Timings and memory addresses will differ on your machine. The values, types and error messages should match the tables above.
- ROS 2 Jazzy tutorial: Using substitutions
- ROS 2 docs (Rolling): substitutions are evaluated during execution and the tip on list concatenation
- example_substitutions_launch.py: IfCondition with PythonExpression
- launch (Jazzy) perform_substitutions_impl.py: the ”.join
- launch (Jazzy) architecture.rst: substitutions are concatenated into a single string
- launch (Jazzy) launch_configuration.py: perform and SubstitutionFailure
- launch (Jazzy) python_expression.py: join, eval, str()
- launch (Jazzy) evaluate_condition_expression_impl.py: true/1/false/0
- launch (Jazzy) opaque_function.py: function(context, *args, **kwargs)
- launch_ros (Jazzy) evaluate_parameters.py: joined string through yaml.safe_load