codecov-io: ## [Current coverage][cc-pull] is **100%**
> Merging [#843][cc-pull] into [master][cc-base-branch] will not change coverage

```diff
@@           master   #843   diff @@
====================================
  Files          29     29          
  Lines        1813   1817     +4   
  Methods         0      0          
  Messages        0      0          
  Branches      305    306     +1   
====================================
+ Hits         1813   1817     +4   
  Misses          0      0          
  Partials        0      0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last updated by [0f64e94...130da74][cc-compare]
[cc-base-branch]: https://codecov.io/gh/falconry/falcon/branch/master?src=pr
[cc-compare]: https://codecov.io/gh/falconry/falcon/compare/0f64e94a5ab49b58b9efa8462fe8c0369428243d...130da74f772ad772b74f74190cc85510333eefef?src=pr
[cc-pull]: https://codecov.io/gh/falconry/falcon/pull/843?src=pr
diff --git a/falcon/api.py b/falcon/api.py
index 3b5539b..557de77 100644
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -40,17 +40,17 @@ class API(object):
 
                 class ExampleComponent(object):
                     def process_request(self, req, resp):
-                        \"""Process the request before routing it.
+                        \"\"\"Process the request before routing it.
 
                         Args:
                             req: Request object that will eventually be
                                 routed to an on_* responder method.
                             resp: Response object that will be routed to
                                 the on_* responder.
-                        \"""
+                        \"\"\"
 
                     def process_resource(self, req, resp, resource, params):
-                        \"""Process the request and resource *after* routing.
+                        \"\"\"Process the request and resource *after* routing.
 
                         Note:
                             This method is only called when the request matches
@@ -69,10 +69,10 @@ class API(object):
                                 template fields, that will be passed to the
                                 resource's responder method as keyword
                                 arguments.
-                        \"""
+                        \"\"\"
 
                     def process_response(self, req, resp, resource)
-                        \"""Post-processing of the response (after routing).
+                        \"\"\"Post-processing of the response (after routing).
 
                         Args:
                             req: Request object.
@@ -80,7 +80,7 @@ class API(object):
                             resource: Resource object to which the request was
                                 routed. May be None if no route was found
                                 for the request.
-                        \"""
+                        \"\"\"
 
             See also :ref:`Middleware <middleware>`.
 
@@ -249,6 +249,10 @@ class API(object):
     def add_route(self, uri_template, resource, *args, **kwargs):
         """Associates a templatized URI path with a resource.
 
+        Note:
+            The following information describes the behavior of
+            Falcon's default router.
+
         A resource is an instance of a class that defines various
         "responder" methods, one for each HTTP method the resource
         allows. Responder names start with `on_` and are named according to
@@ -272,6 +276,10 @@ class API(object):
         field names defined in the template. A field expression consists
         of a bracketed field name.
 
+        Note:
+            Since field names correspond to argument names in responder
+            methods, they must be valid Python identifiers.
+
         For example, given the following template::
 
             /user/{name}
@@ -281,8 +289,8 @@ class API(object):
             def on_put(self, req, resp, name):
                 pass
 
-        Individual path segments may contain one or more field expressions.
-        For example::
+        Individual path segments may contain one or more field
+        expressions::
 
             /repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}
 
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
index 057cf6e..f4e3058 100644
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import keyword
 import re
 
 
@@ -41,14 +42,18 @@ class CompiledRouter(object):
 
     def add_route(self, uri_template, method_map, resource):
         """Adds a route between URI path template and resource."""
-        # Can't start with a number, since these eventually get passed as
-        # args to on_* responders
-        if re.search('{\d', uri_template):
-            raise ValueError('Field names may not start with a digit.')
 
         if re.search('\s', uri_template):
             raise ValueError('URI templates may not include whitespace.')
 
+        # NOTE(kgriffs): Ensure fields are valid Python identifiers,
+        # since they will be passed as kwargs to responders.
+        fields = re.findall('{([^}]*)}', uri_template)
+        for field in fields:
+            is_identifier = re.match('[A-Za-z_][A-Za-z0-9_]+$', field)
+            if not is_identifier or field in keyword.kwlist:
+                raise ValueError('Field names must be valid identifiers.')
+
         path = uri_template.strip('/').split('/')
 
         def insert(nodes, path_index=0):

Add test for hyphens and other non-arg-friendly chars in URI template field names
Currently we only check up-front in `add_route()` for field names that start with a digit or contain whitespace. This can lead to cryptic errors later on when requests are routed to responders.
diff --git a/tests/test_default_router.py b/tests/test_default_router.py
index dec8a8e..9f3c6d5 100644
--- a/tests/test_default_router.py
+++ b/tests/test_default_router.py
@@ -137,13 +137,28 @@ class TestComplexRouting(testing.TestBase):
         )
 
     @ddt.data(
-        '/repos/{org}/{repo}/compare/{simple-vs-complex}',
+        '/repos/{org}/{repo}/compare/{simple_vs_complex}',
         '/repos/{complex}.{vs}.{simple}',
         '/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}/full',
     )
     def test_non_collision(self, template):
         self.router.add_route(template, {}, ResourceWithId(-1))
 
+    @ddt.data(
+        '/{}',
+        '/{9v}',
+        '/{@kgriffs}',
+        '/repos/{simple-thing}/etc',
+        '/repos/{or g}/{repo}/compare/{thing}',
+        '/repos/{org}/{repo}/compare/{}',
+        '/repos/{complex}.{}.{thing}',
+        '/repos/{complex}.{9v}.{thing}/etc',
+    )
+    def test_invalid_field_name(self, template):
+        self.assertRaises(
+            ValueError,
+            self.router.add_route, template, {}, ResourceWithId(-1))
+
     def test_dump(self):
         print(self.router._src)
 

pip install -e .
parse_log_pytest
requirements.txt
nose ddt testtools requests pyyaml pytest
apt-get update apt-get install -y gcc
3.5
tools/test-requires
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==22.2.0
certifi==2021.5.30
charset-normalizer==2.0.12
coverage==6.2
ddt==1.7.2
-e git+https://github.com/falconry/falcon.git@0f64e94a5ab49b58b9efa8462fe8c0369428243d#egg=falcon
fixtures==4.0.1
idna==3.10
importlib-metadata==4.8.3
iniconfig==1.1.1
nose==1.3.7
packaging==21.3
pbr==6.1.1
pluggy==1.0.0
py==1.11.0
pyparsing==3.1.4
pytest==7.0.1
python-mimeparse==1.6.0
PyYAML==6.0.1
requests==2.27.1
six==1.17.0
testtools==2.6.0
tomli==1.2.3
typing_extensions==4.1.1
urllib3==1.26.20
zipp==3.6.0

name: falcon
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
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_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
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - 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
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==22.2.0
      - charset-normalizer==2.0.12
      - coverage==6.2
      - ddt==1.7.2
      - fixtures==4.0.1
      - idna==3.10
      - importlib-metadata==4.8.3
      - iniconfig==1.1.1
      - nose==1.3.7
      - packaging==21.3
      - pbr==6.1.1
      - pluggy==1.0.0
      - py==1.11.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - python-mimeparse==1.6.0
      - pyyaml==6.0.1
      - requests==2.27.1
      - six==1.17.0
      - testtools==2.6.0
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - zipp==3.6.0
prefix: /opt/conda/envs/falcon

tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_1____ tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_3____kgriffs_ tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_6__repos__org___repo__compare___ tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_7__repos__complex______thing_
tests/test_default_router.py::TestRegressionCases::test_recipes tests/test_default_router.py::TestRegressionCases::test_versioned_url tests/test_default_router.py::TestComplexRouting::test_collision_1__teams__collision_ tests/test_default_router.py::TestComplexRouting::test_collision_2__emojis_signs__id_too_ tests/test_default_router.py::TestComplexRouting::test_collision_3__repos__org___repo__compare__complex___vs_____complex2___collision_ tests/test_default_router.py::TestComplexRouting::test_complex_1______5_ tests/test_default_router.py::TestComplexRouting::test_complex_2____full___10_ tests/test_default_router.py::TestComplexRouting::test_complex_3____part___15_ tests/test_default_router.py::TestComplexRouting::test_complex_alt_1______16_ tests/test_default_router.py::TestComplexRouting::test_complex_alt_2____full___17_ tests/test_default_router.py::TestComplexRouting::test_dead_segment_1__teams tests/test_default_router.py::TestComplexRouting::test_dead_segment_2__emojis_signs tests/test_default_router.py::TestComplexRouting::test_dead_segment_3__gists tests/test_default_router.py::TestComplexRouting::test_dead_segment_4__gists_42 tests/test_default_router.py::TestComplexRouting::test_dump tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_2___9v_ tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_4__repos__simple_thing__etc tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_5__repos__or_g___repo__compare__thing_ tests/test_default_router.py::TestComplexRouting::test_invalid_field_name_8__repos__complex___9v___thing__etc tests/test_default_router.py::TestComplexRouting::test_literal tests/test_default_router.py::TestComplexRouting::test_literal_segment tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_01____teams_default___19_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_02____teams_default_members___7_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_03____teams_foo___6_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_04____teams_foo_members___7_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_05____gists_first___20_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_06____gists_first_raw___18_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_07____gists_first_pdf___21_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_08____gists_1776_pdf___21_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_09____emojis_signs_78___13_ tests/test_default_router.py::TestComplexRouting::test_literal_vs_variable_10____emojis_signs_78_small___22_ tests/test_default_router.py::TestComplexRouting::test_malformed_pattern tests/test_default_router.py::TestComplexRouting::test_multivar tests/test_default_router.py::TestComplexRouting::test_non_collision_1__repos__org___repo__compare__simple_vs_complex_ tests/test_default_router.py::TestComplexRouting::test_non_collision_2__repos__complex___vs___simple_ tests/test_default_router.py::TestComplexRouting::test_non_collision_3__repos__org___repo__compare__complex___vs_____complex2__full tests/test_default_router.py::TestComplexRouting::test_not_found_01__this_does_not_exist tests/test_default_router.py::TestComplexRouting::test_not_found_02__user_bogus tests/test_default_router.py::TestComplexRouting::test_not_found_03__repos_racker_falcon_compare_johndoe_master___janedoe_dev_bogus tests/test_default_router.py::TestComplexRouting::test_not_found_04__teams tests/test_default_router.py::TestComplexRouting::test_not_found_05__teams_42_members_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_06__teams_42_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_07__teams_42_undefined_segments tests/test_default_router.py::TestComplexRouting::test_not_found_08__teams_default_members_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_09__teams_default_members_thing_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_10__teams_default_members_thing_undefined_segments tests/test_default_router.py::TestComplexRouting::test_not_found_11__teams_default_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_12__teams_default_undefined_segments tests/test_default_router.py::TestComplexRouting::test_not_found_13__emojis_signs tests/test_default_router.py::TestComplexRouting::test_not_found_14__emojis_signs_0_small tests/test_default_router.py::TestComplexRouting::test_not_found_15__emojis_signs_0_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_16__emojis_signs_0_undefined_segments tests/test_default_router.py::TestComplexRouting::test_not_found_17__emojis_signs_20_small tests/test_default_router.py::TestComplexRouting::test_not_found_18__emojis_signs_20_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_19__emojis_signs_42_undefined tests/test_default_router.py::TestComplexRouting::test_not_found_20__emojis_signs_78_undefined tests/test_default_router.py::TestComplexRouting::test_override tests/test_default_router.py::TestComplexRouting::test_subsegment_not_found tests/test_default_router.py::TestComplexRouting::test_variable
falcon/routing/compiled.py falcon/api.py
falcon/routing/compiled.py falcon/api.py