Weekly Robotics logo
Weekly Robotics Beginner-friendly tutorials, every week
ROS 2 Services and Actions
Software & AI

ROS 2 Services and Actions

Topics are great for streaming data, but not every interaction is a stream. Learn when to reach for a service (quick request/response) versus an action (long-running task with feedback), and how to write both in Python.

Topics are the workhorse of ROS 2 — a camera streams images, an IMU streams orientation, a controller streams velocity commands. But not every interaction between nodes fits the “stream of messages” shape. Sometimes you just want to ask a question and get one answer back, like “is the battery charged?” Sometimes you want to kick off a task that takes thirty seconds, get progress updates while it runs, and be able to cancel it, like “navigate to the kitchen.” Topics are the wrong tool for both.

ROS 2 gives you two purpose-built communication patterns for exactly these cases: services for quick request/response calls, and actions for long-running, cancelable tasks with feedback. This week we build one of each.

What You’ll Learn

  • When to use a service, an action, or a topic — and why the distinction matters
  • How to define a custom service (.srv) and write a service server + client in Python
  • How to define a custom action (.action) and write an action server + client with feedback and cancellation
  • The command-line tools for calling services and actions without writing code
  • The common pitfalls that trip up beginners moving from topics to these newer patterns

Choosing the Right Tool

Here’s the decision that matters before you write any code:

You need to…Use
Continuously stream data (sensor readings, velocity commands)Topic
Ask one question, get one answer, fast (< 1 second)Service
Run a long task with progress updates and the option to cancelAction

A service is a single request and a single response — like calling a function over the network. The caller blocks (or waits asynchronously) until the response arrives. Good examples: “reset the odometry,” “is the gripper open?,” “compute inverse kinematics for this pose.”

An action is built for anything that takes noticeable time: “drive to this waypoint,” “pick up this object,” “run a full mapping pass.” An action gives you three things a service can’t: feedback while the task runs, the ability to cancel partway through, and a final result when it’s done. Internally, an action is actually built out of three services and two topics — but you never touch that plumbing directly.

Topic pub sub Service client server request response Action client server goal feedback… feedback… result
Three shapes of communication: a topic streams continuously, a service is one request and one response, and an action sends a goal once but exchanges feedback repeatedly before a final result.

Part 1: Services

Defining a Custom Service

A service interface lives in a .srv file inside a srv/ directory in your package. It has two sections separated by three dashes: the request fields, then the response fields.

# srv/SetSpeed.srv
float64 speed
---
bool success
string message

To build this, your package.xml needs the interface-generation dependencies, and CMakeLists.txt needs to generate it (interface generation is one of the few places a Python package still needs a small CMake step):

<!-- package.xml -->
<depend>rosidl_default_generators</depend>
# CMakeLists.txt
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/SetSpeed.srv"
)

After colcon build, ROS 2 generates Python request/response classes you can import as your_package.srv.SetSpeed.

Writing a Service Server

# speed_service_server.py
import rclpy
from rclpy.node import Node
from my_robot.srv import SetSpeed

class SpeedService(Node):
    def __init__(self):
        super().__init__('speed_service')
        self.srv = self.create_service(
            SetSpeed, 'set_speed', self.set_speed_callback
        )
        self.current_speed = 0.0

    def set_speed_callback(self, request, response):
        if request.speed < 0.0 or request.speed > 2.0:
            response.success = False
            response.message = 'Speed must be between 0 and 2 m/s'
        else:
            self.current_speed = request.speed
            response.success = True
            response.message = f'Speed set to {request.speed} m/s'
        return response

def main():
    rclpy.init()
    rclpy.spin(SpeedService())
    rclpy.shutdown()

The callback receives an empty response object, fills in its fields, and returns it. create_service takes the service type, a name, and the callback — the same shape you already know from create_subscription.

Writing a Service Client

# speed_service_client.py
import rclpy
from rclpy.node import Node
from my_robot.srv import SetSpeed

class SpeedClient(Node):
    def __init__(self):
        super().__init__('speed_client')
        self.client = self.create_client(SetSpeed, 'set_speed')
        while not self.client.wait_for_service(timeout_sec=1.0):
            self.get_logger().info('Waiting for set_speed service...')

    def send_request(self, speed):
        request = SetSpeed.Request()
        request.speed = speed
        future = self.client.call_async(request)
        rclpy.spin_until_future_complete(self, future)
        return future.result()

def main():
    rclpy.init()
    client = SpeedClient()
    response = client.send_request(1.0)
    client.get_logger().info(f'Success: {response.success}, message: {response.message}')
    rclpy.shutdown()

Two things matter here: wait_for_service blocks until the server is up (so you don’t fire a request into the void), and call_async returns a Future rather than blocking directly — you resolve it with spin_until_future_complete. Calling a service synchronously from inside another callback would freeze your node, so rclpy doesn’t offer that footgun.

Calling a Service from the Command Line

You don’t need a client node just to test a service — the CLI does it for you:

ros2 service list                                  # see everything available
ros2 service type /set_speed                       # check its interface type
ros2 service call /set_speed my_robot/srv/SetSpeed "{speed: 1.0}"

Part 2: Actions

Defining a Custom Action

An action interface lives in an .action file, with three sections: goal, result, and feedback.

# action/NavigateToPoint.action
# Goal
float64 target_x
float64 target_y
---
# Result
bool success
float64 final_distance
---
# Feedback
float64 distance_remaining

Register it in CMakeLists.txt the same way as the service, using "action/NavigateToPoint.action".

Writing an Action Server

# navigate_action_server.py
import time
import rclpy
from rclpy.action import ActionServer
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from my_robot.action import NavigateToPoint

class NavigateServer(Node):
    def __init__(self):
        super().__init__('navigate_server')
        self._action_server = ActionServer(
            self,
            NavigateToPoint,
            'navigate_to_point',
            execute_callback=self.execute_callback,
            cancel_callback=self.cancel_callback,
            callback_group=ReentrantCallbackGroup(),
        )

    def cancel_callback(self, goal_handle):
        self.get_logger().info('Cancel request received')
        return rclpy.action.CancelResponse.ACCEPT

    def execute_callback(self, goal_handle):
        self.get_logger().info(f'Navigating to ({goal_handle.request.target_x}, {goal_handle.request.target_y})')
        feedback = NavigateToPoint.Feedback()
        distance = 10.0

        while distance > 0.0:
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                result = NavigateToPoint.Result()
                result.success = False
                result.final_distance = distance
                return result

            distance -= 1.0
            feedback.distance_remaining = distance
            goal_handle.publish_feedback(feedback)
            time.sleep(0.5)

        goal_handle.succeed()
        result = NavigateToPoint.Result()
        result.success = True
        result.final_distance = 0.0
        return result

def main():
    rclpy.init()
    node = NavigateServer()
    executor = MultiThreadedExecutor()
    rclpy.spin(node, executor=executor)
    rclpy.shutdown()

Notice the MultiThreadedExecutor and ReentrantCallbackGroup — an action’s execute_callback runs for the whole duration of the task, so if you spin with the default single-threaded executor, that one goal blocks every other callback on the node (including the cancel check) until it finishes. This is the single most common thing beginners get wrong with actions.

Writing an Action Client

# navigate_action_client.py
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from my_robot.action import NavigateToPoint

class NavigateClient(Node):
    def __init__(self):
        super().__init__('navigate_client')
        self._client = ActionClient(self, NavigateToPoint, 'navigate_to_point')

    def send_goal(self, x, y):
        self._client.wait_for_server()
        goal = NavigateToPoint.Goal()
        goal.target_x = x
        goal.target_y = y

        future = self._client.send_goal_async(goal, feedback_callback=self.feedback_callback)
        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)
        return result_future.result().result

    def feedback_callback(self, feedback_msg):
        self.get_logger().info(f'Distance remaining: {feedback_msg.feedback.distance_remaining}')

def main():
    rclpy.init()
    client = NavigateClient()
    result = client.send_goal(5.0, 3.0)
    client.get_logger().info(f'Final result: success={result.success}')
    rclpy.shutdown()

Sending a goal is a two-step handshake: send_goal_async first gets you a goal_handle (confirming the server accepted the goal), and only then do you call get_result_async on that handle to wait for the final result. Feedback arrives continuously in between via the callback you passed in.

Calling an Action from the Command Line

ros2 action list
ros2 action send_goal /navigate_to_point my_robot/action/NavigateToPoint "{target_x: 5.0, target_y: 3.0}" --feedback

The --feedback flag prints every feedback message as it arrives, which is a fast way to sanity-check a new action server without writing a client at all.

Common Pitfalls

Calling a service synchronously inside a callback. If you call call_async and then block waiting for the result from inside another subscription or service callback on the same node, you can deadlock — that callback can’t return until the response arrives, but the response can’t be processed until the executor gets back to spinning. Use a separate node, a callback group, or restructure the logic to avoid nested blocking calls.

Forgetting callback_group=ReentrantCallbackGroup(). Without it, a running action blocks its own cancel checks and any other callback on that node. Reach for MultiThreadedExecutor any time a node hosts an action server.

Not handling cancellation. If your execute_callback never checks goal_handle.is_cancel_requested, calling cancel on a goal has no effect — the task runs to completion regardless. Check it inside your loop, not just once at the start.

Choosing a service for something that takes seconds. A service caller has no way to get progress updates and typically expects a fast reply. If a request might take more than a second or two, or you’ll want to cancel it, that’s the sign you actually need an action.

Forgetting the CMake step. Custom .srv and .action files still need rosidl_generate_interfaces in CMakeLists.txt and the generator dependencies in package.xml, even in an otherwise pure-Python package. Skip this and your import of my_robot.srv import SetSpeed fails with a confusing ModuleNotFoundError.

Recap

What you wantHow to do it
Quick request/responseService: create_service / create_client + .srv file
Long task with feedback + cancelAction: ActionServer / ActionClient + .action file
Test a service without coderos2 service call /name pkg/srv/Type "{field: value}"
Test an action without coderos2 action send_goal /name pkg/action/Type "{field: value}" --feedback
Avoid blocking your action serverReentrantCallbackGroup + MultiThreadedExecutor

You now have all three ROS 2 communication patterns in your toolkit: topics for streams, services for quick calls, and actions for long-running, cancelable tasks — the exact pattern behind commands like ros2 launch nav2_bringup sending a NavigateToPose action goal, which you may recognize from Week 18.

Next week: ROS 2 Lifecycle Nodes & Composition — how to give a node explicit configured/active/inactive states, and how to run multiple nodes in a single process for better performance.