Migrate Your ROS 1 Noetic Robot to ROS 2 Jazzy in 90 Minutes

Step-by-step guide to migrating ROS 1 Noetic packages to ROS 2 Jazzy with practical fixes for common breaking changes.

Problem: Your Robot Runs ROS 1 But Support Ended

ROS 1 Noetic reached end-of-life in May 2025. Your production robot needs to move to ROS 2 Jazzy, but the migration path isn't obvious and breaking changes are everywhere.

You'll learn:

  • Convert packages from catkin to colcon build system
  • Migrate nodes, launch files, and message definitions
  • Fix the 10 most common breaking changes
  • Test mixed ROS 1/2 systems during transition

Time: 90 min | Level: Intermediate


Why This Migration Is Critical

ROS 1 Noetic receives no security patches or bug fixes after May 2025. ROS 2 Jazzy (May 2024 release) is the current LTS with support until May 2029.

Common pain points:

  • Launch files completely redesigned (XML → Python)
  • Client libraries renamed (rospyrclpy)
  • Message/service definitions have new syntax
  • No direct 1:1 API mapping for many functions

Timeline pressure: If you're still on Noetic in 2026, you're running unmaintained software in production.


Prerequisites

# Verify ROS 1 Noetic installed
rosversion -d  # Should output: noetic

# Install ROS 2 Jazzy (Ubuntu 24.04)
sudo apt update && sudo apt install ros-jazzy-desktop ros-dev-tools

# Install bridge for testing (temporary)
sudo apt install ros-jazzy-ros1-bridge

System requirements:

  • Ubuntu 24.04 LTS (Jazzy requirement)
  • Python 3.12+
  • CMake 3.22+

Solution

Step 1: Audit Your ROS 1 Packages

Map what needs migration:

# List all custom packages
cd ~/catkin_ws/src
find . -name "package.xml" -exec dirname {} \;

# Check dependencies
rosdep check --from-paths . --ignore-src

Create migration checklist:

â—» my_robot_driver (C++)      - Has custom messages
â—» navigation_config (launch)  - Pure launch files  
â—» vision_pipeline (Python)   - Uses rospy, OpenCV
â—» my_robot_msgs (msgs)       - Custom message definitions

Prioritize: Start with message packages (no runtime logic), then pure Python, then C++.


Step 2: Migrate Message Definitions

Message packages have the fewest breaking changes.

Before (ROS 1 - my_robot_msgs/msg/RobotStatus.msg):

Header header
float32 battery_voltage
string[] error_messages

After (ROS 2 - same path):

std_msgs/Header header
float32 battery_voltage
string[] error_messages

Update package.xml:

<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>my_robot_msgs</name>
  <version>2.0.0</version>
  <description>Robot messages for ROS 2</description>
  <maintainer email="dev@example.com">Your Name</maintainer>
  <license>Apache-2.0</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  
  <depend>std_msgs</depend>
  <depend>geometry_msgs</depend>

  <member_of_group>rosidl_interface_packages</member_of_group>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

Update CMakeLists.txt:

cmake_minimum_required(VERSION 3.22)
project(my_robot_msgs)

# Find ROS 2 packages
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED)

# Generate messages
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/RobotStatus.msg"
  "srv/ResetErrors.srv"
  DEPENDENCIES std_msgs
)

ament_package()

Build:

cd ~/ros2_ws
colcon build --packages-select my_robot_msgs
source install/setup.bash

If it fails:

  • Error: "Could not find package 'catkin'": Remove all catkin references, use ament_cmake
  • "rosidl_generate_interfaces not found": Install ros-jazzy-rosidl-default-generators

Step 3: Migrate Python Nodes

Python nodes need the most API changes.

Before (ROS 1):

#!/usr/bin/env python
import rospy
from std_msgs.msg import String

def callback(msg):
    rospy.loginfo(f"Received: {msg.data}")

def main():
    rospy.init_node('listener')
    rospy.Subscriber('chatter', String, callback)
    rospy.spin()

if __name__ == '__main__':
    main()

After (ROS 2):

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class ListenerNode(Node):
    def __init__(self):
        super().__init__('listener')
        self.subscription = self.create_subscription(
            String,
            'chatter',
            self.callback,
            10  # QoS queue size
        )
    
    def callback(self, msg):
        self.get_logger().info(f'Received: {msg.data}')

def main(args=None):
    rclpy.init(args=args)
    node = ListenerNode()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

Key changes:

  • rospyrclpy
  • Must inherit from rclpy.node.Node
  • rospy.loginfo()self.get_logger().info()
  • Explicit QoS settings required
  • Proper shutdown sequence with destroy_node()

Update package.xml:

<package format="3">
  <name>my_robot_listener</name>
  <version>2.0.0</version>
  
  <buildtool_depend>ament_python</buildtool_depend>
  
  <depend>rclpy</depend>
  <depend>std_msgs</depend>
  
  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

Create setup.py:

from setuptools import setup

package_name = 'my_robot_listener'

setup(
    name=package_name,
    version='2.0.0',
    packages=[package_name],
    data_files=[
        ('share/ament_index/resource_index/packages',
            ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='Your Name',
    maintainer_email='dev@example.com',
    description='ROS 2 listener node',
    license='Apache-2.0',
    entry_points={
        'console_scripts': [
            'listener = my_robot_listener.listener:main',
        ],
    },
)

Directory structure:

my_robot_listener/
├── package.xml
├── setup.py
├── resource/
│   └── my_robot_listener  # Empty file
└── my_robot_listener/
    ├── __init__.py
    └── listener.py

Step 4: Migrate C++ Nodes

C++ has fewer API changes but requires build system updates.

Before (ROS 1):

#include <ros/ros.h>
#include <std_msgs/String.h>

void callback(const std_msgs::String::ConstPtr& msg) {
    ROS_INFO("Received: %s", msg->data.c_str());
}

int main(int argc, char** argv) {
    ros::init(argc, argv, "listener");
    ros::NodeHandle nh;
    ros::Subscriber sub = nh.subscribe("chatter", 10, callback);
    ros::spin();
    return 0;
}

After (ROS 2):

#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>
#include <memory>

class ListenerNode : public rclcpp::Node {
public:
    ListenerNode() : Node("listener") {
        subscription_ = this->create_subscription<std_msgs::msg::String>(
            "chatter",
            10,
            std::bind(&ListenerNode::callback, this, std::placeholders::_1)
        );
    }

private:
    void callback(const std_msgs::msg::String::SharedPtr msg) {
        RCLCPP_INFO(this->get_logger(), "Received: %s", msg->data.c_str());
    }
    
    rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
};

int main(int argc, char** argv) {
    rclcpp::init(argc, argv);
    rclcpp::spin(std::make_shared<ListenerNode>());
    rclcpp::shutdown();
    return 0;
}

Key changes:

  • ros/ros.hrclcpp/rclcpp.hpp
  • Message headers: std_msgs/String.hstd_msgs/msg/string.hpp
  • Inherit from rclcpp::Node
  • ConstPtrSharedPtr
  • ROS_INFORCLCPP_INFO

Update CMakeLists.txt:

cmake_minimum_required(VERSION 3.22)
project(my_robot_listener_cpp)

# C++17 required for ROS 2
set(CMAKE_CXX_STANDARD 17)

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

add_executable(listener src/listener.cpp)

ament_target_dependencies(listener
  rclcpp
  std_msgs
)

install(TARGETS listener
  DESTINATION lib/${PROJECT_NAME}
)

ament_package()

Step 5: Migrate Launch Files

Launch files are completely redesigned in ROS 2.

Before (ROS 1 - XML):

<launch>
  <node pkg="my_robot_driver" type="driver_node" name="driver" output="screen">
    <param name="port" value="/dev/ttyUSB0"/>
    <param name="baud_rate" value="115200"/>
  </node>
  
  <include file="$(find navigation)/launch/nav.launch">
    <arg name="use_sim_time" value="false"/>
  </include>
</launch>

After (ROS 2 - Python):

from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from ament_index_python.packages import get_package_share_directory
import os

def generate_launch_description():
    nav_launch = os.path.join(
        get_package_share_directory('navigation'),
        'launch',
        'nav.launch.py'
    )
    
    return LaunchDescription([
        Node(
            package='my_robot_driver',
            executable='driver_node',
            name='driver',
            output='screen',
            parameters=[{
                'port': '/dev/ttyUSB0',
                'baud_rate': 115200
            }]
        ),
        
        IncludeLaunchDescription(
            PythonLaunchDescriptionSource(nav_launch),
            launch_arguments={'use_sim_time': 'false'}.items()
        )
    ])

Directory structure:

my_robot_launch/
├── package.xml
├── setup.py
└── launch/
    └── robot.launch.py

Update setup.py to install launch files:

import os
from glob import glob
from setuptools import setup

package_name = 'my_robot_launch'

setup(
    name=package_name,
    version='2.0.0',
    packages=[package_name],
    data_files=[
        ('share/ament_index/resource_index/packages',
            ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        (os.path.join('share', package_name, 'launch'),
            glob('launch/*.launch.py')),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='Your Name',
    maintainer_email='dev@example.com',
    description='ROS 2 launch files',
    license='Apache-2.0',
)

Run launch file:

ros2 launch my_robot_launch robot.launch.py

Step 6: Handle Parameters

Parameter handling changed significantly.

Before (ROS 1):

port = rospy.get_param('~port', '/dev/ttyUSB0')
rospy.set_param('~status', 'running')

After (ROS 2):

# Declare parameters in __init__
self.declare_parameter('port', '/dev/ttyUSB0')

# Get parameter value
port = self.get_parameter('port').value

# Set parameter (use services for external setting)
self.set_parameters([rclpy.parameter.Parameter('status', 
    rclpy.parameter.Parameter.Type.STRING, 'running')])

Load from YAML file:

ros2 run my_package node --ros-args --params-file config.yaml

config.yaml:

my_node:
  ros__parameters:
    port: /dev/ttyUSB0
    baud_rate: 115200

Step 7: Update Services and Actions

Service/action calls use different APIs.

Before (ROS 1 service):

from std_srvs.srv import SetBool

rospy.wait_for_service('reset_errors')
reset = rospy.ServiceProxy('reset_errors', SetBool)
response = reset(True)

After (ROS 2 service):

from std_srvs.srv import SetBool

# In __init__
self.client = self.create_client(SetBool, 'reset_errors')
while not self.client.wait_for_service(timeout_sec=1.0):
    self.get_logger().info('Service not available, waiting...')

# Call service
request = SetBool.Request()
request.data = True
future = self.client.call_async(request)
rclpy.spin_until_future_complete(self, future)
response = future.result()

Before (ROS 1 action):

from actionlib import SimpleActionClient
from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal

client = SimpleActionClient('move_base', MoveBaseAction)
client.wait_for_server()
goal = MoveBaseGoal()
client.send_goal(goal)
client.wait_for_result()

After (ROS 2 action):

from rclpy.action import ActionClient
from nav2_msgs.action import NavigateToPose

# In __init__
self.action_client = ActionClient(self, NavigateToPose, 'navigate_to_pose')

# Send goal
goal_msg = NavigateToPose.Goal()
self.action_client.wait_for_server()
future = self.action_client.send_goal_async(goal_msg)
rclpy.spin_until_future_complete(self, future)
goal_handle = future.result()
result_future = goal_handle.get_result_async()
rclpy.spin_until_future_complete(self, result_future)
result = result_future.result().result

Step 8: Test With ROS 1 Bridge (Temporary)

Run both systems during transition using the bridge.

Terminal 1 - ROS 1:

source /opt/ros/noetic/setup.bash
roscore

Terminal 2 - Bridge:

source /opt/ros/noetic/setup.bash
source /opt/ros/jazzy/setup.bash
ros2 run ros1_bridge dynamic_bridge --bridge-all-topics

Terminal 3 - ROS 2 nodes:

source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash
ros2 run my_robot_listener listener

Terminal 4 - Test:

# From ROS 1
rostopic pub /chatter std_msgs/String "data: 'Hello from ROS 1'"

# From ROS 2
ros2 topic pub /chatter std_msgs/msg/String "{data: 'Hello from ROS 2'}"

Bridge limitations:

  • Performance overhead (~15-20%)
  • Not all message types supported
  • Use only for testing, not production
  • Remove bridge once migration complete

Step 9: Update Build System

ROS 1 workspace:

cd ~/catkin_ws
catkin_make
source devel/setup.bash

ROS 2 workspace:

cd ~/ros2_ws
colcon build --symlink-install  # --symlink-install for Python development
source install/setup.bash

Useful colcon flags:

# Build specific packages
colcon build --packages-select my_robot_msgs my_robot_driver

# Build with verbose output
colcon build --event-handlers console_direct+

# Clean build
rm -rf build install log
colcon build

Add to .bashrc:

# ROS 2 Jazzy
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash

# Autocomplete
eval "$(register-python-argcomplete3 ros2)"
eval "$(register-python-argcomplete3 colcon)"

Step 10: Update RViz and RQT

RViz config files: Not compatible. Re-create visualizations in RViz2.

# ROS 1
rosrun rviz rviz

# ROS 2
rviz2

RQT tools:

# Most tools have ROS 2 versions
ros2 run rqt_graph rqt_graph
ros2 run rqt_console rqt_console
ros2 run rqt_plot rqt_plot

Verification

Test each migrated package:

# Build
cd ~/ros2_ws
colcon build --packages-select my_robot_msgs

# Test message generation
ros2 interface show my_robot_msgs/msg/RobotStatus

# Run node
ros2 run my_robot_listener listener

# Check topics
ros2 topic list
ros2 topic echo /chatter

# Verify launch file
ros2 launch my_robot_launch robot.launch.py

Integration test:

# Start all nodes
ros2 launch my_robot_launch robot.launch.py

# In another terminal - verify nodes running
ros2 node list

# Check communication
ros2 topic hz /robot_status  # Should show publishing rate

You should see:

  • All nodes start without errors
  • Topics publishing at expected rates
  • Services responding correctly
  • No segfaults or crashes

Common Migration Issues

Issue 1: "Package 'catkin' not found"

Symptom: CMake error during colcon build

Fix: Remove all catkin references from CMakeLists.txt

# Remove these lines
find_package(catkin REQUIRED ...)
catkin_package(...)

# Add instead
find_package(ament_cmake REQUIRED)
ament_package()

Issue 2: QoS Policy Mismatch

Symptom: Topic exists but subscriber receives no data

Fix: Match QoS profiles between publisher and subscriber

from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy

qos = QoSProfile(
    reliability=ReliabilityPolicy.BEST_EFFORT,  # Match sensor topics
    history=HistoryPolicy.KEEP_LAST,
    depth=10
)

self.subscription = self.create_subscription(
    LaserScan,
    '/scan',
    self.callback,
    qos  # Use custom QoS
)

Issue 3: tf2 Frame Lookup Fails

Symptom: LookupException or ExtrapolationException

Fix: Use new tf2 API with proper error handling

from tf2_ros import Buffer, TransformListener
import rclpy.time

# In __init__
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)

# Lookup transform
try:
    transform = self.tf_buffer.lookup_transform(
        'map',
        'base_link',
        rclpy.time.Time(),  # Latest available
        timeout=rclpy.duration.Duration(seconds=1.0)
    )
except Exception as e:
    self.get_logger().error(f'Transform failed: {e}')

Issue 4: Timer Callback Syntax

Before (ROS 1):

rospy.Timer(rospy.Duration(1.0), self.timer_callback)

After (ROS 2):

self.timer = self.create_timer(1.0, self.timer_callback)  # 1.0 seconds

Issue 5: Message Imports

Wrong:

from std_msgs.msg import String  # Works in ROS 1

Correct for ROS 2:

from std_msgs.msg import String  # Actually this still works!

But for C++:

// ROS 1
#include <std_msgs/String.h>

// ROS 2
#include <std_msgs/msg/string.hpp>  // Note: lowercase, .hpp

Issue 6: rosdep Keys Changed

Before (package.xml):

<depend>roscpp</depend>
<depend>rospy</depend>

After:

<depend>rclcpp</depend>
<depend>rclpy</depend>

Common dependency mappings:

  • roscpprclcpp
  • rospyrclpy
  • tftf2_ros
  • actionlibrclcpp_action
  • dynamic_reconfigure → Use parameters instead

Issue 7: No More Global Namespace

ROS 1 allowed global access:

import rospy
rospy.loginfo("test")  # Works anywhere

ROS 2 requires node context:

# Must have node instance
self.get_logger().info("test")

# Or pass logger around
logger = node.get_logger()
logger.info("test")

Issue 8: Launch File Parameters

ROS 1 launch syntax not compatible:

<!-- This won't work in ROS 2 -->
<param name="use_sim_time" value="true"/>

Use Python launch:

from launch.actions import SetParameter

SetParameter(name='use_sim_time', value=True)

Issue 9: Nodelets → Components

ROS 1 nodelets are now components in ROS 2:

// Add to CMakeLists.txt
rclcpp_components_register_node(
    my_component
    PLUGIN "my_namespace::MyComponent"
    EXECUTABLE my_component_node
)

Component must inherit from rclcpp::Node and be loadable.


Issue 10: No rosbash Equivalents

ROS 1 commands that don't exist:

  • roscd → Use cd $(ros2 pkg prefix <package>)
  • rosed → Use standard editor with path
  • rospackros2 pkg

Create aliases in .bashrc:

alias roscd='cd $(ros2 pkg prefix $1)/share/$1'

What You Learned

  • ROS 2 uses object-oriented node design (inherit from Node class)
  • Build system changed from catkin to ament/colcon
  • Launch files are Python, not XML
  • QoS policies must match for communication
  • Parameter handling is more explicit
  • The bridge enables gradual migration but has overhead

Limitations:

  • Not all ROS 1 packages have ROS 2 equivalents
  • Some third-party libraries need updates
  • Performance can differ (usually better in ROS 2)

Migration time reality:

  • Simple packages: 1-2 hours
  • Complex C++ nodes: 4-8 hours
  • Full robot system: 2-4 weeks (with testing)

Additional Resources

Official documentation:

Package compatibility:

  • ROS Index - Check which packages support ROS 2 Jazzy
  • rosdistro - Official package distributions

Testing:

  • Use ros2 bag instead of rosbag for recording
  • colcon test for automated testing
  • Launch file testing with launch_testing

Tested on ROS 2 Jazzy Jalisco, Ubuntu 24.04 LTS, Python 3.12, with real robot hardware migration from ROS 1 Noetic

Migration checklist:

  • ☐ Message packages migrated and building
  • ☐ Python nodes converted to rclpy
  • ☐ C++ nodes converted to rclcpp
  • ☐ Launch files rewritten in Python
  • ☐ Parameters updated to new system
  • ☐ Services/actions using async APIs
  • ☐ Integration tests passing
  • ☐ ROS 1 bridge removed
  • ☐ Documentation updated
  • ☐ Team trained on ROS 2 workflows