
Building Your First ROS2 Robot: From URDF File to Live Movement
Your hardware is on the bench. ROS 2 Humble (or Iron, or Jazzy) is installed and sourced. You have three browser tabs open, each containing a slightly different URDF for what is nominally the same robot — one from a vendor repo with broken mesh paths, one from a graduate student's class project missing every inertial tag, one auto-exported from CAD with joint limits in degrees instead of radians. None of them launch cleanly.
This is where most engineers building their first ros2 robot lose three to four weeks: hunting scattered GitHub repos, patching malformed XML, and rebuilding sensor configs before a single ros2 launch command produces joint motion. The work isn't conceptually hard. It's just death by a thousand undocumented assumptions.
URDF — the Unified Robot Description Format — is "the standard ROS format for robot modeling," as Addison Sears-Collins puts it in his ROS 2 visualization guide. It serves as the single source of truth feeding visualization in RViz, motion planning in MoveIt, and simulation in Gazebo (MiRobot Lesson 9). Get the URDF right and downstream stacks compose. Get it wrong and every layer above it lies to you.
The promise of this guide: source a verified URDF, adapt it to your hardware in hours rather than weeks, and watch your robot move in Gazebo by end of day. Here is the exact path.

Table of Contents
- Verified URDF vs. Community URDF vs. Writing From Scratch
- Finding and Validating a URDF That Matches Your Hardware
- Adapting a Base URDF to Your Exact Hardware Configuration
- Wiring Your URDF Into a ROS 2 Launch File
- Debugging the Five Failures That Block First Movement
- Cross-Simulator Compatibility and Contributing Adaptations Back
Verified URDF vs. Community URDF vs. Writing From Scratch
Every ROS 2 robot project starts with one of three sources for the robot description, and the choice determines whether your first launch happens in hours or weeks. The decision isn't about engineering pride. It's about where you want to spend your time: on the application logic that matters to your project, or on rebuilding fundamentals other engineers have already solved.
| Source | Time to First Launch | Inertial/Collision Tags | Sensor Plugins | Best For |
|---|---|---|---|---|
| Verified URDF (URDF Hub) | Hours | Pre-tuned, peer-reviewed | Pre-configured for Gazebo, Isaac Sim, PyBullet | Standard platforms (UR5e, Panda, TurtleBot3, Spot, KUKA iiwa 14) |
| Community URDF (GitHub) | Days to weeks | Often missing or placeholder | Inconsistent; usually Gazebo-only | Niche or modified robots with no official model |
| Hand-written from scratch | Weeks | You author every value | You author every plugin | Custom hardware with no analog; learning exercise |
The "missing inertial tags" column hides the most expensive failure mode. Educational and community URDFs frequently use placeholder inertia — unit mass on every link, identity-matrix inertia tensors — and the result in simulation is dramatic. Robots "explode," joints oscillate without damping, or links fall through the floor. Gazebo's contact troubleshooting documentation and MiRobot's URDF lesson both flag this as the most common cause of unstable first-launch simulations.
Vendor and verified URDFs short-circuit that pain. TurtleBot3's turtlebot3_description package ships with calibrated sensor frames, tuned inertial parameters, and tested Gazebo plugins out of the box (ROBOTIS docs). You inherit the validation work of every engineer who came before you.
Complexity scaling matters too. A mobile base typically needs 3–10 links per the Nav2 URDF tutorial. Industrial arms like the UR5 or Franka Panda often define 20+ links plus virtual end-effector frames (Universal Robots ROS 2 description). Writing 20+ links from scratch — with correct inertias, mesh references, transmissions, and Gazebo plugins — without prior URDF experience is almost never the right move. The verified-URDF route through a curated library like URDF Hub gives you peer-reviewed models with collision meshes, joint limits, and sensor configs already validated, then lets you spend your effort on the parts that are actually unique to your project.
Finding and Validating a URDF That Matches Your Hardware
Even a verified URDF requires validation against your actual hardware. Skipping this step is the number-one cause of the "looks right but physics feel wrong" debugging cycles that consume week two of a project.
- Inventory your kinematic chain. Count joints, identify degrees of freedom, and determine your base type — fixed base for stationary arms, floating or planar for mobile platforms. URDF supports only six joint types:
revolute,continuous,prismatic,floating,planar, andfixed(ROS Wiki – URDF/XML/Joint). The format represents the robot as a tree with no closed loops, meaning each link has exactly one parent joint (ROS Wiki – URDF). If your robot has parallel mechanisms — delta arms, four-bar linkages, certain grippers — you'll need to split into multiple URDFs or add SRDF/custom kinematics. Verify before moving on: sketch the kinematic tree on paper before downloading anything. - Search for a matching verified model and confirm peer-reviewed status. Filter candidates by manufacturer, DOF count, and simulator target (Gazebo, Isaac Sim, PyBullet, MuJoCo). Confirm the model carries a verification badge, MIT or Apache 2.0 licensing, and explicit ROS 2 Humble/Iron/Jazzy compatibility. Verify before moving on: the model card lists collision meshes, joint limits, and sensor configurations explicitly. A model without those three artifacts is incomplete, regardless of how authoritative its source appears.
- Download both URDF and XACRO versions. XACRO is a macro language that generates URDF; ROS documentation recommends authoring in XACRO for parameterization and re-use. Use XACRO when you'll be customizing the robot. Use plain URDF for static deployment where no parameters change. Verify before moving on: run
xacro robot.xacro > robot.urdfand diff against the provided URDF — they should match modulo whitespace. - Confirm SI units throughout. URDF requires meters, kilograms, radians, and seconds across every dynamics and geometry value (ROS Wiki – URDF). Mixing imperial values — a depressingly common community-repo mistake — will silently break physics without throwing a parse error. Verify before moving on: spot-check joint limits (radians, not degrees), link masses (kilograms, not pounds), and mesh scales (meters, not millimeters or inches).
- Validate mesh file paths and collision geometry locally. Place the model in your workspace using the standard ROS 2 layout:
urdf/for robot descriptions,meshes/for geometry,launch/for Python launch files,rviz/for visualization configs (Nav2 convention). Confirm everypackage://URI resolves to a real file. Verify before moving on: runcheck_urdf robot.urdf— the output should print the kinematic tree cleanly with zero warnings. - Document hardware deviations before customization. Write a short delta sheet: "URDF assumes Robotiq 2F-85 gripper; I have a custom 3-finger end-effector. URDF specifies 28 Nm joint torque; my motor datasheet says 12 Nm." This becomes your edit list for the next section. Verify before moving on: every divergence has a corresponding URDF element identified — joint, link, transmission, or Gazebo plugin.

Adapting a Base URDF to Your Exact Hardware Configuration
Adaptation is rarely a rewrite. It's surgical edits to specific XML elements. Addison Sears-Collins observes in his URDF tutorial that early failures usually stem from incorrect file paths or missing frames, not from complex math. Each joint defines a parent link, a child link, a 3D origin transform, an axis, position/velocity/effort limits, and optional damping/friction (ROS Wiki – URDF/XML/Joint). Those five attributes cover roughly 90% of edits you'll ever make.
Swapping Actuators (Gearbox Ratio or Torque Changes)
The most common adaptation: your motor isn't the one the original URDF was authored against. The edits live in two elements per joint — <limit> and <dynamics>.
<!-- Before: original URDF with reference actuator -->
<joint name="shoulder_pan_joint" type="revolute">
<parent link="base_link"/>
<child link="shoulder_link"/>
<axis xyz="0 0 1"/>
<limit effort="150.0" velocity="3.15" lower="-3.14159" upper="3.14159"/>
<dynamics damping="0.1" friction="0.0"/>
</joint>
<!-- After: your actuator with 70% of stall torque, lower top speed -->
<joint name="shoulder_pan_joint" type="revolute">
<parent link="base_link"/>
<child link="shoulder_link"/>
<axis xyz="0 0 1"/>
<limit effort="42.0" velocity="2.10" lower="-3.14159" upper="3.14159"/>
<dynamics damping="0.2" friction="0.05"/>
</joint>
Revolute and prismatic joints require finite position limits; continuous joints omit lower and upper (ROS Wiki – URDF/XML/Joint). If you don't know your motor's effort limit, look up the datasheet stall torque and apply a 70% safety margin — that's the practical rule most controls engineers use for sustained operation.
The difference between a "works eventually" URDF and one that mirrors your hardware is often three lines of XML — your inertia tensors, damping values, and effort limits.
Adding Custom Sensors (Camera, Lidar, Force/Torque)
Sensor integration in URDF for Gazebo uses <gazebo> extension tags wrapping a <sensor> element with a plugin reference. Mounting a depth camera to an end-effector link looks like this:
<gazebo reference="tool0">
<sensor type="depth" name="wrist_camera">
<update_rate>30.0</update_rate>
<camera>
<horizontal_fov>1.047</horizontal_fov>
<image><width>640</width><height>480</height><format>R8G8B8</format></image>
<clip><near>0.05</near><far>3.0</far></clip>
</camera>
<plugin name="camera_controller" filename="libgazebo_ros_camera.so">
<ros>
<namespace>/wrist</namespace>
<remapping>image_raw:=color/image_raw</remapping>
</ros>
<frame_name>wrist_camera_optical</frame_name>
</plugin>
</sensor>
</gazebo>
Ian Chen and colleagues in the Gazebo URDF integration guide put it cleanly: treat URDF as the interface to ROS, and SDF/Gazebo extensions as the interface to the simulator. Your sensor's ROS topics are determined by the <namespace> and <remapping> values inside the <plugin> block — set them deliberately to avoid collisions when you eventually run multiple sensors or multiple robots in one simulation.
Modifying Joint Limits and Collision Meshes for Workspace Constraints
When your robot operates in a confined cell, tighten <limit lower="..." upper="..."/> so the simulated robot respects the same envelope as the physical one. A UR5e with lower="-3.14159" upper="3.14159" on every joint will happily plan trajectories that smash into your enclosure if your motion planner trusts the URDF.
Collision performance is the second lever. Detailed visual meshes are fine for rendering but expensive for contact solvers. Gazebo's performance guidance recommends keeping individual mesh triangle counts in the low thousands to low tens of thousands per link, and using simplified collision geometry separate from visual geometry. Swap a 50,000-triangle CAD export for a primitive:
<collision>
<origin xyz="0 0 0.15" rpy="0 0 0"/>
<geometry>
<cylinder radius="0.06" length="0.30"/>
</geometry>
</collision>
A bounding cylinder or box is sufficient for the vast majority of manipulation contact reasoning. Save the high-poly mesh for <visual>, where it costs you only frame rate, not solver stability.

Wiring Your URDF Into a ROS 2 Launch File
The ROS 2 launch system uses a Python declarative API — launch_ros.actions.Node, DeclareLaunchArgument, Command substitutions — rather than ROS 1's XML launch files (ROS 2 launch documentation). Minimal URDF integration for a ros2 robot is three actions: load the URDF into the robot_description parameter, start robot_state_publisher, and spawn the model into a simulator.
Step 1: Create the Launch Directory and Python Template
Add launch/robot_bringup.launch.py to your package. The standard ROS 2 layout per Nav2 conventions keeps launch files alongside urdf/, meshes/, and rviz/ directories.
from launch import LaunchDescription
from launch.substitutions import Command, PathJoinSubstitution, LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description():
# actions go here
return LaunchDescription([])
Step 2: Load the URDF (or XACRO) Into robot_description
Use Command(['xacro ', ...]) to expand XACRO at launch time, or read a plain URDF file directly. robot_state_publisher in ROS 2 expects the URDF to be passed as the robot_description parameter and consumes a stream of sensor_msgs/JointState messages (ROS 2 robot_state_publisher tutorial).
pkg_share = FindPackageShare('my_robot_description')
xacro_file = PathJoinSubstitution([pkg_share, 'urdf', 'my_robot.urdf.xacro'])
robot_desc = Command(['xacro ', xacro_file])
Step 3: Launch robot_state_publisher to Publish the TF Tree
Each URDF link becomes a TF2 frame. robot_state_publisher consumes joint states and publishes the consistent transform tree to /tf and /tf_static. RViz, Nav2, and MoveIt all depend on this tree being correct and complete.
robot_state_pub = Node(
package='robot_state_publisher',
executable='robot_state_publisher',
output='screen',
parameters=[{'robot_description': robot_desc}]
)
Step 4: Spawn the Robot in Gazebo
Add the gazebo_ros spawn_entity.py node, which reads the URDF from the /robot_description topic and instantiates the model in the running Gazebo world.
spawn_entity = Node(
package='gazebo_ros',
executable='spawn_entity.py',
arguments=['-topic', 'robot_description', '-entity', 'my_robot'],
output='screen'
)
Gazebo Classic defaults to gravity of 9.80665 m/s² and a simulation time step of 0.001 s (1000 Hz real-time update rate) per Gazebo's physics parameters documentation — adequate as a starting point before any tuning. Nav2's reference differential-drive setup uses two driven wheels plus a caster (Nav2 URDF guide), a useful layout to mimic if you're standing up a mobile base.
Step 5: Confirm a Joint State Source Is Active
Either joint_state_publisher_gui (for manual slider testing) or your controllers from ros2_control / gazebo_ros2_control must publish to /joint_states. Without that stream, robot_state_publisher has nothing to consume and the TF tree freezes at the default pose. For first-launch sanity checks, the GUI is the fastest path:
joint_state_gui = Node(
package='joint_state_publisher_gui',
executable='joint_state_publisher_gui',
output='screen'
)
Swap to real controllers once movement is verified.
Step 6: Send Your First Movement Command
Build with colcon build, source the workspace, then ros2 launch your_pkg robot_bringup.launch.py. In a second terminal, publish a joint command:
ros2 topic pub /joint_states sensor_msgs/msg/JointState \
'{name: ["shoulder_pan_joint"], position: [0.5]}'
Watch the joint move in Gazebo. This pipeline — package, URDF, joint state publisher, robot_state_publisher, RViz or Gazebo — is the canonical benchmark from the ROS 2 URDF tutorial.
Debugging the Five Failures That Block First Movement
When the launch file runs but the robot doesn't move, the cause is almost never the URDF model's geometry. It's an integration error. The same five issues account for the vast majority of stuck first-launch sessions.
Ninety percent of URDF-to-launch failures are namespace mismatches or missing gazebo_ros plugins — not the model itself.
Failure 1: Robot Spawns But Doesn't Respond to Joint Commands
Diagnosis: The gazebo_ros2_control plugin block is missing from the URDF, or your controller's command topic doesn't match what you're publishing to. The model is in the world, but nothing is bridging ROS topics to Gazebo joint actuators.
Fix: Add a <gazebo><plugin name="gazebo_ros2_control" filename="libgazebo_ros2_control.so">...</plugin></gazebo> block referencing your controller config YAML. Confirm with ros2 topic list that the expected command topic (for example, /joint_trajectory_controller/joint_trajectory) is present. Confirm with ros2 control list_controllers that the controller is loaded and in the active state.
Failure 2: Physics Feel Wrong — Robot Falls Through Floor, Jitters, or Flies Away
Diagnosis: Inertia tensors are placeholder values (unit mass, identity-matrix inertia) or collision mesh origins are offset from visual mesh origins. Both Gazebo's contact troubleshooting and MiRobot's URDF lesson flag that incorrect inertia causes robots to "explode" or fall through the floor in simulation.
Fix: Recompute inertia from CAD using your modeler's mass-properties tool, or apply a conservative uniform box approximation for each link. Confirm that every <collision> element shares the same <origin> as its corresponding <visual> unless an offset is intentional. If physics still feel off, drop the simulation time step from 0.001 s to 0.0005 s temporarily to isolate whether you have a solver-stability issue or a model issue.
Failure 3: TF Tree Is Broken — RViz Shows No Frames or Red Xs
Diagnosis: robot_state_publisher isn't running, link or joint names are inconsistent (typo between parent and child references), or joint_states isn't being published.
Fix: Run ros2 run tf2_tools view_frames and inspect the generated PDF. Confirm robot_state_publisher is in your launch description and reports no errors at startup. Check that every joint's <parent link="..."/> and <child link="..."/> references a <link> element that actually exists in the same URDF. Frame conventions to verify: X forward, Y left, Z up (Nav2 URDF guide).
Failure 4: Sensor Data Not Publishing to Expected Topic
Diagnosis: The sensor plugin's <namespace> or topic remapping in the URDF <gazebo> block doesn't match what your subscriber expects. Gazebo plugins live in the simulator-interface layer, not the ROS-interface layer, so their topic names are configured inside the <plugin> element rather than at the URDF link level.
Fix: Open the URDF, locate the sensor's <plugin> element, and confirm the <ros><namespace> and <remapping> values match your downstream node's expected topics. Cross-reference with ros2 topic list and ros2 topic echo on the suspected topic. Treat URDF as the ROS interface and <gazebo> extensions as the simulator interface — sensor topic naming lives in the latter.
Failure 5: Launch Hangs on "Waiting for Model to Spawn"
Diagnosis: The Gazebo server isn't fully started before spawn_entity.py runs, or the robot_description parameter is empty because of a silent XACRO expansion error.
Fix: Wrap your spawn node in a TimerAction(period=3.0, actions=[spawn_entity_node]) to delay spawning until Gazebo is ready. Manually run xacro your_robot.xacro from the command line and read the output for parse errors that the launch system swallowed. Confirm the launch sequence is Gazebo first, then robot_state_publisher, then spawn_entity — out-of-order startup is a frequent culprit.

Cross-Simulator Compatibility and Contributing Adaptations Back
A URDF that runs cleanly in Gazebo Classic isn't automatically portable. Community discussions on ROS Discourse note that simulator-specific XML fragments — <gazebo> tags, Isaac Sim extension parameters, PyBullet-specific loaders — often require rework when porting models. Verifying compatibility across your intended simulators before locking in a deployment target prevents week-three surprises.
| Target | URDF Format Accepted | Required Plugin/Extension | ROS 2 Distros |
|---|---|---|---|
| Gazebo Classic | URDF (auto-converted to SDF) | <gazebo> tags + gazebo_ros2_control | Humble, Iron |
| Gazebo (Ignition / modern) | URDF or SDF (SDF preferred) | gz_ros2_control | Humble, Iron, Jazzy |
| NVIDIA Isaac Sim | URDF via Isaac importer | OmniGraph action graphs | Humble, Iron, Jazzy |
| PyBullet | URDF native | pybullet.loadURDF() | Distro-agnostic (Python API) |
| MuJoCo | URDF via converter to MJCF | Custom contact/actuator tuning | Distro-agnostic (XML) |
Gazebo's own URDF vs. SDF documentation explicitly recommends SDF as the primary format for Gazebo-specific simulation features — advanced friction models, contact parameters, sensor noise models, lighting, and terrain. Gazebo will auto-convert URDF to SDF at load time, but URDF-only models surrender access to those features unless you add them via <gazebo> extension tags. If your simulation needs sit firmly inside what URDF expresses, you don't need to care. If you're tuning realistic contact dynamics for manipulation research, expect to author SDF additions.
The ROS 1 to ROS 2 migration pattern is worth understanding even if you're starting fresh, because much of the URDF tooling you'll inherit comes from ROS 1 lineage. The URDF model format itself is unchanged between ROS 1 and ROS 2 (ROS Wiki URDF tutorials). What changes is the consumer side: tf becomes tf2, and XML launch files become Python launch descriptions (ROS 2 robot_state_publisher tutorial). If you ported a URDF from a ROS 1 package, the model itself is fine — the launch file is the migration work.
A single bug fix or sensor addition you document helps the next fifty engineers building the same robot.
When your adapted URDF fixes a missing inertial tag, corrects a broken mesh path, or adds a sensor plugin the original lacked, the productive next step is to submit it back. The contribution loop is what separates a curated repository from a graveyard of stale GitHub forks. Scattered community URDFs decay because nobody owns them; a peer-reviewed repository compounds because each fix you document saves the next engineer hours of identical debugging. MIT and Apache 2.0 licensing on models hosted in URDF Hub keeps both upstream contribution and downstream commercial use friction-free.
Before You Call It Done
check_urdf robot.urdfreturns the kinematic tree with zero warningsros2 launchbrings uprobot_state_publisher, Gazebo, and spawns the model with no errorsros2 topic listshows your expected joint command and sensor topics- Every joint name in launch-file topic subscriptions matches the URDF
<joint name="...">exactly - Gazebo physics step size and gravity confirmed (default 0.001 s, 9.80665 m/s² unless tuned)
- Collision meshes simplified to primitives or low-poly geometry per Gazebo performance guidance
- Sensor plugin namespaces and remappings verified against subscriber expectations
- Hardware deviation sheet saved alongside the URDF in your repo
- Tested in at least one secondary simulator if cross-platform deployment is planned
- (If improving an existing model) Pull request prepared with changelog, tested distros, and mesh assets
Hit every box and your ros2 robot has cleared the bar that separates "moves on my machine" from "deployable to the next engineer who needs it." That's the threshold where simulation work stops being throwaway and starts being infrastructure.