diff --git a/doc/source/python-roles.rst b/doc/source/python-roles.rst
index dc6303110..cb812f86d 100644
--- a/doc/source/python-roles.rst
+++ b/doc/source/python-roles.rst
@@ -12,6 +12,7 @@ Python Roles
 .. zuul:autorole:: ensure-sphinx
 .. zuul:autorole:: ensure-tox
 .. zuul:autorole:: ensure-twine
+.. zuul:autorole:: ensure-uv
 .. zuul:autorole:: ensure-virtualenv
 .. zuul:autorole:: fetch-coverage-output
 .. zuul:autorole:: fetch-python-sdist-output
diff --git a/roles/ensure-uv/README.rst b/roles/ensure-uv/README.rst
new file mode 100644
index 000000000..66f9c6bdb
--- /dev/null
+++ b/roles/ensure-uv/README.rst
@@ -0,0 +1,42 @@
+Ensure uv is installed
+
+Look for ``uv``, and if not found, install it via ``pip`` into a
+virtual environment for the current user.
+
+**Role Variables**
+
+.. zuul:rolevar:: ensure_uv_version
+   :default: ''
+
+   Version specifier to select the version of uv.  The default is the
+   latest version.
+
+.. zuul:rolevar:: ensure_uv_venv_path
+   :default: {{ ansible_user_dir }}/.local/uv
+
+   Directory for the Python venv where uv will be installed.
+
+.. zuul:rolevar:: ensure_uv_global_symlink
+   :default: False
+
+   Install a symlink to the uv executable into ``/usr/local/bin/uv``.
+   This can be useful when scripts need to be run that expect to find
+   uv in a more standard location and plumbing through the value
+   of ``ensure_uv_executable`` would be onerous.
+
+   Setting this requires root access, so should only be done in
+   circumstances where root access is available.
+
+**Output Variables**
+
+.. zuul:rolevar:: ensure_uv_executable
+   :default: uv
+
+   After running this role, ``ensure_uv_executable`` will be set as the path
+   to a valid ``uv``.
+
+   At role runtime, look for an existing ``uv`` at this specific
+   path.  Note the default (``uv``) effectively means to find uv in
+   the current ``$PATH``.  For example, if your base image
+   pre-installs uv in an out-of-path environment, set this so the
+   role does not attempt to install the user version.
diff --git a/roles/ensure-uv/defaults/main.yaml b/roles/ensure-uv/defaults/main.yaml
new file mode 100644
index 000000000..a6060f028
--- /dev/null
+++ b/roles/ensure-uv/defaults/main.yaml
@@ -0,0 +1,4 @@
+ensure_uv_global_symlink: false
+ensure_uv_version: ""
+ensure_uv_executable: uv
+ensure_uv_venv_path: "{{ ansible_user_dir }}/.local/uv"
diff --git a/roles/ensure-uv/tasks/main.yaml b/roles/ensure-uv/tasks/main.yaml
new file mode 100644
index 000000000..87dd44e8a
--- /dev/null
+++ b/roles/ensure-uv/tasks/main.yaml
@@ -0,0 +1,44 @@
+- name: Install pip
+  include_role:
+    name: ensure-pip
+
+- name: Check if uv is installed
+  shell: |
+    command -v {{ ensure_uv_executable }} {{ ensure_uv_venv_path }}/bin/uv || exit 1
+  args:
+    executable: /bin/bash
+  register: uv_preinstalled
+  failed_when: false
+
+- name: Export preinstalled ensure_uv_executable
+  set_fact:
+    ensure_uv_executable: "{{ uv_preinstalled.stdout_lines[0] }}"
+    cacheable: true
+  when: uv_preinstalled.rc == 0
+
+- name: Install uv to local env
+  when: uv_preinstalled.rc != 0
+  block:
+    - name: Create local venv
+      command: "{{ ensure_pip_virtualenv_command }} {{ ensure_uv_venv_path }}"
+
+    - name: Install uv to local venv
+      command: "{{ ensure_uv_venv_path }}/bin/pip install uv{{ ensure_uv_version }}"
+
+    - name: Export installed ensure_uv_executable path
+      set_fact:
+        ensure_uv_executable: "{{ ensure_uv_venv_path }}/bin/uv"
+        cacheable: true
+
+- name: Output uv version
+  command: "{{ ensure_uv_executable }} --version"
+
+- name: Make global symlink
+  when:
+    - ensure_uv_global_symlink
+    - ensure_uv_executable != '/usr/local/bin/uv'
+  file:
+    state: link
+    src: "{{ ensure_uv_executable }}"
+    dest: /usr/local/bin/uv
+  become: yes
diff --git a/test-playbooks/ensure-uv.yaml b/test-playbooks/ensure-uv.yaml
new file mode 100644
index 000000000..ce20ee05e
--- /dev/null
+++ b/test-playbooks/ensure-uv.yaml
@@ -0,0 +1,35 @@
+- hosts: all
+  name: Test ensure-uv installs into user environment
+  tasks:
+    - name: Verify uv is not installed
+      command: "uv --version"
+      register: result
+      failed_when: result.rc == 0
+    - name: Run ensure-uv with uv not installed
+      include_role:
+        name: ensure-uv
+    - name: Verify ensure_uv_executable is set
+      assert:
+        that:
+          - ensure_uv_executable == ansible_user_dir + '/.local/uv/bin/uv'
+    - name: Verify uv is installed
+      command: "{{ ensure_uv_executable }} --version"
+      register: result
+      failed_when: result.rc != 0
+
+- hosts: all
+  name: Test ensure-uv when ensure_uv_executable is set to an already installed uv
+  tasks:
+    - name: Create a virtualenv
+      command: "{{ ensure_pip_virtualenv_command }} {{ ansible_user_dir }}/uv-venv"
+    - name: Install uv to local venv
+      command: "{{ ansible_user_dir }}/uv-venv/bin/pip install uv"
+    - name: Run ensure-uv pointing to an already installed uv
+      include_role:
+        name: ensure-uv
+      vars:
+        ensure_uv_executable: "{{ ansible_user_dir }}/uv-venv/bin/uv"
+    - name: Verify ensure_uv_executable is set to the virtualenv uv
+      assert:
+        that:
+          - ensure_uv_executable == ansible_user_dir + '/uv-venv/bin/uv'
diff --git a/zuul-tests.d/python-jobs.yaml b/zuul-tests.d/python-jobs.yaml
index 06847127f..d9c1611c9 100644
--- a/zuul-tests.d/python-jobs.yaml
+++ b/zuul-tests.d/python-jobs.yaml
@@ -291,6 +291,75 @@
         - name: ubuntu-noble
           label: ubuntu-noble
 
+- job:
+    name: zuul-jobs-test-ensure-uv
+    description: Test the ensure-uv role
+    files:
+      - roles/ensure-uv/.*
+      - test-playbooks/ensure-uv.yaml
+    run: test-playbooks/ensure-uv.yaml
+    tags: all-platforms
+
+- job:
+    name: zuul-jobs-test-ensure-uv-centos-9-stream
+    description: Test the ensure-uv role on centos-9-stream
+    parent: zuul-jobs-test-ensure-uv
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: centos-9-stream
+          label: centos-9-stream
+
+- job:
+    name: zuul-jobs-test-ensure-uv-debian-bookworm
+    description: Test the ensure-uv role on debian-bookworm
+    parent: zuul-jobs-test-ensure-uv
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: debian-bookworm
+          label: debian-bookworm
+
+- job:
+    name: zuul-jobs-test-ensure-uv-debian-bullseye
+    description: Test the ensure-uv role on debian-bullseye
+    parent: zuul-jobs-test-ensure-uv
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: debian-bullseye
+          label: debian-bullseye
+
+- job:
+    name: zuul-jobs-test-ensure-uv-ubuntu-focal
+    description: Test the ensure-uv role on ubuntu-focal
+    parent: zuul-jobs-test-ensure-uv
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: ubuntu-focal
+          label: ubuntu-focal
+
+- job:
+    name: zuul-jobs-test-ensure-uv-ubuntu-jammy
+    description: Test the ensure-uv role on ubuntu-jammy
+    parent: zuul-jobs-test-ensure-uv
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: ubuntu-jammy
+          label: ubuntu-jammy
+
+- job:
+    name: zuul-jobs-test-ensure-uv-ubuntu-noble
+    description: Test the ensure-uv role on ubuntu-noble
+    parent: zuul-jobs-test-ensure-uv
+    tags: auto-generated
+    nodeset:
+      nodes:
+        - name: ubuntu-noble
+          label: ubuntu-noble
+
 - job:
     name: zuul-jobs-test-fetch-sphinx-tarball
     description: Test the fetch-sphinx-tarball role
@@ -502,6 +571,12 @@
         - zuul-jobs-test-ensure-tox-ubuntu-focal
         - zuul-jobs-test-ensure-tox-ubuntu-jammy
         - zuul-jobs-test-ensure-tox-ubuntu-noble
+        - zuul-jobs-test-ensure-uv-centos-9-stream
+        - zuul-jobs-test-ensure-uv-debian-bookworm
+        - zuul-jobs-test-ensure-uv-debian-bullseye
+        - zuul-jobs-test-ensure-uv-ubuntu-focal
+        - zuul-jobs-test-ensure-uv-ubuntu-jammy
+        - zuul-jobs-test-ensure-uv-ubuntu-noble
         - zuul-jobs-test-fetch-sphinx-tarball-centos-9-stream
         - zuul-jobs-test-fetch-sphinx-tarball-debian-bookworm
         - zuul-jobs-test-fetch-sphinx-tarball-debian-bullseye