diff --git a/MODULE.bazel b/MODULE.bazel
index 390b5780574a..4a6d107e6185 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -32,3 +32,9 @@ register_toolchains(
# file, so that downstream projects can consume Drake exclusively via bzlmod
# (and so that we can delete our WORKSPACE files prior to Bazel 9 which drops
# suppose for it).
+
+bazel_dep(name = "xacro")
+local_path_override(
+ module_name = "xacro",
+ path = "../xacro",
+)
diff --git a/examples/multibody/strandbeest/BUILD.bazel b/examples/multibody/strandbeest/BUILD.bazel
index 8db74b64f5d9..11b3007bb120 100644
--- a/examples/multibody/strandbeest/BUILD.bazel
+++ b/examples/multibody/strandbeest/BUILD.bazel
@@ -1,7 +1,7 @@
load("//tools/lint:lint.bzl", "add_lint_tests")
load("//tools/skylark:drake_cc.bzl", "drake_cc_binary")
load("//tools/skylark:drake_py.bzl", "drake_py_unittest")
-load("//tools/workspace/ros_xacro_internal:defs.bzl", "xacro_filegroup")
+load("@xacro//bazel:defs.bzl", "xacro_filegroup")
package(default_visibility = ["//visibility:private"])
diff --git a/tools/workspace/default.bzl b/tools/workspace/default.bzl
index 4ce1df39d21b..6da53f533d38 100644
--- a/tools/workspace/default.bzl
+++ b/tools/workspace/default.bzl
@@ -76,7 +76,6 @@ load("//tools/workspace/pycodestyle:repository.bzl", "pycodestyle_repository")
load("//tools/workspace/python:repository.bzl", "python_repository")
load("//tools/workspace/qdldl_internal:repository.bzl", "qdldl_internal_repository") # noqa
load("//tools/workspace/qhull_internal:repository.bzl", "qhull_internal_repository") # noqa
-load("//tools/workspace/ros_xacro_internal:repository.bzl", "ros_xacro_internal_repository") # noqa
load("//tools/workspace/rules_cc:repository.bzl", "rules_cc_repository") # noqa
load("//tools/workspace/rules_java:repository.bzl", "rules_java_repository")
load("//tools/workspace/rules_license:repository.bzl", "rules_license_repository") # noqa
@@ -302,8 +301,6 @@ def add_default_repositories(
qdldl_internal_repository(name = "qdldl_internal", mirrors = mirrors)
if "qhull_internal" not in excludes:
qhull_internal_repository(name = "qhull_internal", mirrors = mirrors)
- if "ros_xacro_internal" not in excludes:
- ros_xacro_internal_repository(name = "ros_xacro_internal", mirrors = mirrors) # noqa
if "rules_cc" not in excludes:
rules_cc_repository(name = "rules_cc", mirrors = mirrors)
if "rules_java" not in excludes:
diff --git a/tools/workspace/ros_xacro_internal/BUILD.bazel b/tools/workspace/ros_xacro_internal/BUILD.bazel
deleted file mode 100644
index bae15aaf9f14..000000000000
--- a/tools/workspace/ros_xacro_internal/BUILD.bazel
+++ /dev/null
@@ -1,25 +0,0 @@
-load("//tools/lint:lint.bzl", "add_lint_tests")
-load("//tools/skylark:drake_py.bzl", "drake_py_unittest")
-load(":defs.bzl", "xacro_filegroup")
-
-xacro_filegroup(
- name = "samples",
- srcs = [
- "test/sample1.xml.xacro",
- "test/sample2.xml.xacro",
- ],
- data = [
- "test/box.xml",
- ],
-)
-
-drake_py_unittest(
- name = "xacro_smoke_test",
- data = [
- "test/sample1.xml.expected",
- "test/sample2.xml.expected",
- ":samples",
- ],
-)
-
-add_lint_tests()
diff --git a/tools/workspace/ros_xacro_internal/defs.bzl b/tools/workspace/ros_xacro_internal/defs.bzl
deleted file mode 100644
index 6a3845dc70ea..000000000000
--- a/tools/workspace/ros_xacro_internal/defs.bzl
+++ /dev/null
@@ -1,96 +0,0 @@
-def _xacro_impl(ctx):
- out = ctx.actions.declare_file(ctx.label.name)
- ctx.actions.run(
- inputs = [ctx.file.src] + ctx.files.data,
- outputs = [out],
- executable = ctx.executable._tool,
- arguments = [
- "-o",
- out.path,
- ctx.file.src.path,
- ],
- )
- return [DefaultInfo(
- files = depset([out]),
- data_runfiles = ctx.runfiles(files = [out]),
- )]
-
-_xacro_rule = rule(
- attrs = {
- "src": attr.label(
- mandatory = True,
- allow_single_file = True,
- ),
- "data": attr.label_list(
- allow_files = True,
- ),
- "_tool": attr.label(
- default = "@ros_xacro_internal//:xacro",
- cfg = "host",
- executable = True,
- ),
- },
- implementation = _xacro_impl,
-)
-
-def xacro_file(
- name,
- src = None,
- data = [],
- tags = [],
- visibility = None):
- """Runs xacro on a single input file, creating a single output file.
-
- Xacro is the ROS XML macro tool; http://wiki.ros.org/xacro.
-
- Args:
- name: The xml output file of this rule.
- src: The single xacro input file of this rule.
- data: Optional supplemental files required by the src file.
- """
- _xacro_rule(
- name = name,
- src = src,
- data = data,
- tags = tags,
- visibility = visibility,
- )
-
-def xacro_filegroup(
- name,
- srcs = [],
- data = [],
- tags = [],
- visibility = None):
- """Runs xacro on several input files, creating a filegroup of the output.
-
- The output filenames will match the input filenames but with the ".xacro"
- suffix removed.
-
- Xacro is the ROS XML macro tool; http://wiki.ros.org/xacro.
-
- Args:
- name: The name of the filegroup label.
- srcs: The xacro input files of this rule.
- data: Optional supplemental files required by the srcs.
- """
- outs = []
- for src in srcs:
- if not src.endswith(".xacro"):
- fail("xacro_filegroup srcs should be named *.xacro not {}".format(
- src,
- ))
- out = src[:-6]
- outs.append(out)
- xacro_file(
- name = out,
- src = src,
- data = data,
- tags = tags,
- visibility = ["//visibility:private"],
- )
- native.filegroup(
- name = name,
- srcs = outs,
- visibility = visibility,
- )
diff --git a/tools/workspace/ros_xacro_internal/package.BUILD.bazel b/tools/workspace/ros_xacro_internal/package.BUILD.bazel
deleted file mode 100644
index 08c0e881adc9..000000000000
--- a/tools/workspace/ros_xacro_internal/package.BUILD.bazel
+++ /dev/null
@@ -1,31 +0,0 @@
-# -*- bazel -*-
-
-load("@drake//tools/skylark:py.bzl", "py_binary")
-load("@drake//tools/workspace:generate_file.bzl", "generate_file")
-
-licenses(["notice"]) # BSD-3-Clause
-
-generate_file(
- name = "ros_xacro_main.py",
- # This is the same as scripts/xacro from upstream, except that we lose the
- # unused shebang line and we use a filename that is not subject to import
- # path conflicts.
- content = "import xacro; xacro.main()",
-)
-
-py_binary(
- name = "xacro_bin",
- main = "ros_xacro_main.py",
- srcs = ["ros_xacro_main.py"] + glob([
- "xacro/**/*.py",
- ], allow_empty = False),
- imports = ["."],
- python_version = "PY3",
- srcs_version = "PY3",
-)
-
-alias(
- name = "xacro",
- actual = ":xacro_bin",
- visibility = ["//visibility:public"],
-)
diff --git a/tools/workspace/ros_xacro_internal/patches/disable-import-warning.patch b/tools/workspace/ros_xacro_internal/patches/disable-import-warning.patch
deleted file mode 100644
index bab811611071..000000000000
--- a/tools/workspace/ros_xacro_internal/patches/disable-import-warning.patch
+++ /dev/null
@@ -1,15 +0,0 @@
-Disable an 'import rosgraph' console warning.
-
-Reasoning for not upstreaming this patch: Drake-specific build option.
-
---- xacro/cli.py
-+++ xacro/cli.py
-@@ -97,7 +97,7 @@
- mappings = load_mappings(argv)
- filtered_args = [a for a in argv if REMAP not in a] # filter-out REMAP args
- except ImportError as e:
-- warning(e)
-+ # warning(e)
- mappings = {}
- filtered_args = argv
-
diff --git a/tools/workspace/ros_xacro_internal/repository.bzl b/tools/workspace/ros_xacro_internal/repository.bzl
deleted file mode 100644
index b2211aa410a8..000000000000
--- a/tools/workspace/ros_xacro_internal/repository.bzl
+++ /dev/null
@@ -1,16 +0,0 @@
-load("//tools/workspace:github.bzl", "github_archive")
-
-def ros_xacro_internal_repository(
- name,
- mirrors = None):
- github_archive(
- name = name,
- repository = "ros/xacro",
- commit = "2.0.11",
- sha256 = "0c9b1619f1cdcf863e5a29fe8c034ae5c310e39722ff089d5d1e440c4e41967f", # noqa
- build_file = ":package.BUILD.bazel",
- patches = [
- ":patches/disable-import-warning.patch",
- ],
- mirrors = mirrors,
- )
diff --git a/tools/workspace/ros_xacro_internal/test/box.xml b/tools/workspace/ros_xacro_internal/test/box.xml
deleted file mode 100644
index 7caa3756ec0e..000000000000
--- a/tools/workspace/ros_xacro_internal/test/box.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/tools/workspace/ros_xacro_internal/test/sample1.xml.expected b/tools/workspace/ros_xacro_internal/test/sample1.xml.expected
deleted file mode 100644
index 70e0f9e127bc..000000000000
--- a/tools/workspace/ros_xacro_internal/test/sample1.xml.expected
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/tools/workspace/ros_xacro_internal/test/sample1.xml.xacro b/tools/workspace/ros_xacro_internal/test/sample1.xml.xacro
deleted file mode 100644
index 9b3aee572f61..000000000000
--- a/tools/workspace/ros_xacro_internal/test/sample1.xml.xacro
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/tools/workspace/ros_xacro_internal/test/sample2.xml.expected b/tools/workspace/ros_xacro_internal/test/sample2.xml.expected
deleted file mode 100644
index 3c6877270162..000000000000
--- a/tools/workspace/ros_xacro_internal/test/sample2.xml.expected
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/tools/workspace/ros_xacro_internal/test/sample2.xml.xacro b/tools/workspace/ros_xacro_internal/test/sample2.xml.xacro
deleted file mode 100644
index 994cfe0e4667..000000000000
--- a/tools/workspace/ros_xacro_internal/test/sample2.xml.xacro
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/tools/workspace/ros_xacro_internal/test/xacro_smoke_test.py b/tools/workspace/ros_xacro_internal/test/xacro_smoke_test.py
deleted file mode 100644
index d0f46f8969be..000000000000
--- a/tools/workspace/ros_xacro_internal/test/xacro_smoke_test.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import subprocess
-import unittest
-
-
-class XacroSmokeTest(unittest.TestCase):
-
- def _diff_file(self, filename):
- self.maxDiff = None
- with open(filename) as f:
- actual = f.read()
- with open(filename + ".expected") as f:
- expected = f.read()
- # We use an atypical (expected, actual) order here so that the diff
- # shown during failure has the correct sense of added vs removed lines.
- self.assertMultiLineEqual(expected, actual)
-
- def test_samples(self):
- """Check use of xacro_filegroup and the expected result.
- """
- self._diff_file("tools/workspace/ros_xacro_internal/test/sample1.xml")
- self._diff_file("tools/workspace/ros_xacro_internal/test/sample2.xml")