diff --git a/docs/gen.rst b/docs/gen.rst
index 52c7f55d..cf42269f 100644
--- a/docs/gen.rst
+++ b/docs/gen.rst
@@ -21,7 +21,6 @@
    .. autoexception:: Return
 
    .. autofunction:: with_timeout
-   .. autoexception:: TimeoutError
 
    .. autofunction:: sleep
 
diff --git a/docs/releases/v4.2.0.rst b/docs/releases/v4.2.0.rst
index d9484379..93493ee1 100644
--- a/docs/releases/v4.2.0.rst
+++ b/docs/releases/v4.2.0.rst
@@ -49,7 +49,7 @@ be unlocked *without* acquiring it. This encouraged unorthodox patterns; in
 Tornado, just use `~.Semaphore.acquire`.
 
 Toro's ``Event.wait`` raised a ``Timeout`` exception after a timeout. In
-Tornado, `.Event.wait` raises `tornado.gen.TimeoutError`.
+Tornado, `.Event.wait` raises ``tornado.gen.TimeoutError``.
 
 Toro's ``Condition.wait`` also raised ``Timeout``, but in Tornado, the `.Future`
 returned by `.Condition.wait` resolves to False after a timeout::
diff --git a/tornado/gen.py b/tornado/gen.py
index 2e9e9a6a..5558f986 100644
--- a/tornado/gen.py
+++ b/tornado/gen.py
@@ -89,7 +89,7 @@ from tornado.concurrent import Future, TracebackFuture, is_future, chain_future
 from tornado.ioloop import IOLoop
 from tornado.log import app_log
 from tornado import stack_context
-from tornado.util import PY3, raise_exc_info
+from tornado.util import PY3, raise_exc_info, TimeoutError
 
 try:
     try:
@@ -154,10 +154,6 @@ class ReturnValueIgnoredError(Exception):
     pass
 
 
-class TimeoutError(Exception):
-    """Exception raised by ``with_timeout``."""
-
-
 def _value_from_stopiteration(e):
     try:
         # StopIteration has a value attribute beginning in py33.
@@ -871,10 +867,10 @@ def maybe_future(x):
 def with_timeout(timeout, future, quiet_exceptions=()):
     """Wraps a `.Future` (or other yieldable object) in a timeout.
 
-    Raises `TimeoutError` if the input future does not complete before
-    ``timeout``, which may be specified in any form allowed by
-    `.IOLoop.add_timeout` (i.e. a `datetime.timedelta` or an absolute time
-    relative to `.IOLoop.time`)
+    Raises `tornado.util.TimeoutError` if the input future does not
+    complete before ``timeout``, which may be specified in any form
+    allowed by `.IOLoop.add_timeout` (i.e. a `datetime.timedelta` or
+    an absolute time relative to `.IOLoop.time`)
 
     If the wrapped `.Future` fails after it has timed out, the exception
     will be logged unless it is of a type contained in ``quiet_exceptions``
diff --git a/tornado/ioloop.py b/tornado/ioloop.py
index 0527e922..73d0cbdb 100644
--- a/tornado/ioloop.py
+++ b/tornado/ioloop.py
@@ -48,7 +48,7 @@ from tornado.concurrent import TracebackFuture, is_future
 from tornado.log import app_log, gen_log
 from tornado.platform.auto import set_close_exec, Waker
 from tornado import stack_context
-from tornado.util import PY3, Configurable, errno_from_exception, timedelta_to_seconds
+from tornado.util import PY3, Configurable, errno_from_exception, timedelta_to_seconds, TimeoutError
 
 try:
     import signal
@@ -70,10 +70,6 @@ except ImportError:
 _POLL_TIMEOUT = 3600.0
 
 
-class TimeoutError(Exception):
-    pass
-
-
 class IOLoop(Configurable):
     """A level-triggered I/O loop.
 
@@ -457,7 +453,7 @@ class IOLoop(Configurable):
 
         The keyword-only argument ``timeout`` may be used to set
         a maximum duration for the function.  If the timeout expires,
-        a `TimeoutError` is raised.
+        a `tornado.util.TimeoutError` is raised.
 
         This method is useful in conjunction with `tornado.gen.coroutine`
         to allow asynchronous calls in a ``main()`` function::
diff --git a/tornado/locks.py b/tornado/locks.py
index 4f9ecf6d..c883aa23 100644
--- a/tornado/locks.py
+++ b/tornado/locks.py
@@ -99,7 +99,7 @@ class Condition(_TimeoutGarbageCollector):
         # Wait up to 1 second.
         yield condition.wait(timeout=datetime.timedelta(seconds=1))
 
-    The method raises `tornado.gen.TimeoutError` if there's no notification
+    The method raises `tornado.util.TimeoutError` if there's no notification
     before the deadline.
     """
 
@@ -220,7 +220,7 @@ class Event(object):
     def wait(self, timeout=None):
         """Block until the internal flag is true.
 
-        Returns a Future, which raises `tornado.gen.TimeoutError` after a
+        Returns a Future, which raises `tornado.util.TimeoutError` after a
         timeout.
         """
         if timeout is None:
@@ -480,7 +480,7 @@ class Lock(object):
     def acquire(self, timeout=None):
         """Attempt to lock. Returns a Future.
 
-        Returns a Future, which raises `tornado.gen.TimeoutError` after a
+        Returns a Future, which raises `tornado.util.TimeoutError` after a
         timeout.
         """
         return self._block.acquire(timeout)
diff --git a/tornado/queues.py b/tornado/queues.py
index 00fa4e22..73589611 100644
--- a/tornado/queues.py
+++ b/tornado/queues.py
@@ -166,7 +166,7 @@ class Queue(object):
     def put(self, item, timeout=None):
         """Put an item into the queue, perhaps waiting until there is room.
 
-        Returns a Future, which raises `tornado.gen.TimeoutError` after a
+        Returns a Future, which raises `tornado.util.TimeoutError` after a
         timeout.
 
         ``timeout`` may be a number denoting a time (on the same
@@ -204,7 +204,7 @@ class Queue(object):
         """Remove and return an item from the queue.
 
         Returns a Future which resolves once an item is available, or raises
-        `tornado.gen.TimeoutError` after a timeout.
+        `tornado.util.TimeoutError` after a timeout.
 
         ``timeout`` may be a number denoting a time (on the same
         scale as `tornado.ioloop.IOLoop.time`, normally `time.time`), or a
@@ -258,7 +258,7 @@ class Queue(object):
     def join(self, timeout=None):
         """Block until all items in the queue are processed.
 
-        Returns a Future, which raises `tornado.gen.TimeoutError` after a
+        Returns a Future, which raises `tornado.util.TimeoutError` after a
         timeout.
         """
         return self._finished.wait(timeout)
diff --git a/tornado/util.py b/tornado/util.py
index bfd80beb..aee718ae 100644
--- a/tornado/util.py
+++ b/tornado/util.py
@@ -84,6 +84,16 @@ except ImportError:
     is_finalizing = _get_emulated_is_finalizing()
 
 
+class TimeoutError(Exception):
+    """Exception raised by `.with_timeout` and `.IOLoop.run_sync`.
+
+    .. versionchanged:: 5.0:
+       Unified ``tornado.gen.TimeoutError`` and
+       ``tornado.ioloop.TimeoutError`` as ``tornado.util.TimeoutError``.
+       Both former names remain as aliases.
+    """
+
+
 class ObjectDict(_ObjectDictBase):
     """Makes a dictionary behave like an object, with attribute-style access.
     """

Too many TimeoutErrors
This would probably warrant unifying.
```
>>> ioloop.TimeoutError
tornado.ioloop.TimeoutError
>>> gen.TimeoutError
tornado.gen.TimeoutError
>>> gen.TimeoutError is ioloop.TimeoutError
False
```

diff --git a/tornado/test/import_test.py b/tornado/test/import_test.py
index 88d02e02..d8cf14a5 100644
--- a/tornado/test/import_test.py
+++ b/tornado/test/import_test.py
@@ -45,3 +45,9 @@ class ImportTest(unittest.TestCase):
             pass
         else:
             import tornado.curl_httpclient
+
+    def test_import_aliases(self):
+        # Ensure we don't delete formerly-documented aliases accidentally.
+        import tornado.ioloop, tornado.gen, tornado.util
+        self.assertIs(tornado.ioloop.TimeoutError, tornado.util.TimeoutError)
+        self.assertIs(tornado.gen.TimeoutError, tornado.util.TimeoutError)

pip install -e .
parse_log_pytest
pytest
futures mock monotonic trollius sphinx sphinx_rtd_theme codecov virtualenv pytest
apt-get update apt-get install -y gcc
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
Babel==2.11.0
certifi==2021.5.30
charset-normalizer==2.0.12
codecov==2.1.13
coverage==6.2
distlib==0.3.9
docutils==0.18.1
filelock==3.4.1
futures==2.2.0
idna==3.10
imagesize==1.4.1
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
Jinja2==3.0.3
MarkupSafe==2.0.1
mock==5.2.0
monotonic==1.6
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
platformdirs==2.4.0
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
Pygments==2.14.0
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
pytz==2025.2
requests==2.27.1
six==1.17.0
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-rtd-theme==2.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
-e git+https://github.com/tornadoweb/tornado.git@3163c606f06065b296df70bef5390afa548825d8#egg=tornado
trollius==2.1.post2
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
urllib3==1.26.20
virtualenv==20.17.1
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: tornado
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - babel==2.11.0
      - charset-normalizer==2.0.12
      - codecov==2.1.13
      - coverage==6.2
      - distlib==0.3.9
      - docutils==0.18.1
      - filelock==3.4.1
      - futures==2.2.0
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - jinja2==3.0.3
      - markupsafe==2.0.1
      - mock==5.2.0
      - monotonic==1.6
      - platformdirs==2.4.0
      - pygments==2.14.0
      - pytz==2025.2
      - requests==2.27.1
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-rtd-theme==2.0.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - trollius==2.1.post2
      - urllib3==1.26.20
      - virtualenv==20.17.1
prefix: /opt/conda/envs/tornado

tornado/test/import_test.py::ImportTest::test_import_aliases
tornado/test/import_test.py::ImportTest::test_import_everything tornado/test/import_test.py::ImportTest::test_import_pycurl
tornado/gen.py docs/releases/v4.2.0.rst docs/gen.rst tornado/locks.py tornado/queues.py tornado/ioloop.py tornado/util.py
tornado/gen.py docs/releases/v4.2.0.rst docs/gen.rst tornado/locks.py tornado/queues.py tornado/ioloop.py tornado/util.py