Repository: scikit-hep/cabinetry (language: python)

Issue:
Reading histogram input instead of ntuples
Currently `cabinetry` is designed with columnar data input in mind (e.g. ntuples). Support for histogram input would be useful to also have. A conceptual issue with histograms is that the construction of the histograms already must use information about regions, samples, systematic uncertainties. This information then has to be duplicated in the `cabinetry` config to build a workspace, and duplication of information is not ideal.

### For context: ntuple reading
When reading ntuple inputs, the main steps of the workflow that are relevant to this discussion are the following:
```python
cabinetry.template_builder.create_histograms(config)
cabinetry.template_postprocessor.run(config)
ws = cabinetry.workspace.build(config)
```
The first step identifies all template histograms that have to be built, reads the input columnar data at the locations provided in the config, and stores the resulting histograms in a specific format in the location the user provides in the config. The second step reads these histograms that were just produced, and applies optional post-processing, creating another set of histograms with a fixed naming scheme (in the same user-provided location). The third step uses the histograms in this location to build a workspace (using the post-processed histograms when available, and the original histograms otherwise).

### Possible approaches

#### 1) Users provide histograms in expected format and location
The user puts all histograms in the location `cabinetry` expects, correctly formatted and named. The name is given by [`cabinetry.histo.build_name`](https://github.com/alexander-held/cabinetry/blob/d202b5b5efe78fcfa4a71239de61b70b42f8679b/src/cabinetry/histo.py#L238-L244). A `cabinetry` config is required to run `cabinetry.workspace.build` as a first step of the workflow. Since the config currently requires ntuple-related arguments, those could be filled with placeholders since they are not needed. A better solution would be to make those arguments optional.

#### 2) Users provide a function that points to histograms
Users could provide a function that works similar to [`cabinetry.histo.Histogram.from_config`](https://github.com/alexander-held/cabinetry/blob/d202b5b5efe78fcfa4a71239de61b70b42f8679b/src/cabinetry/histo.py#L92-L123), but loads histograms from wherever they are stored. The mechanisms would probably be similar to [template building overrides](https://cabinetry.readthedocs.io/en/latest/advanced.html#overrides-for-template-building). The template histogram creation step in `cabinetry` would be bypassed (see issue below), possibly leading to some issues with existing functionality (e.g. `cabinetry.visualize.templates` would have to be rewritten).

#### 3) Re-use template building overrides
The existing [template building overrides](https://cabinetry.readthedocs.io/en/latest/advanced.html#overrides-for-template-building) technique could be used to provide a custom function to `cabinetry` that returns histogram information. This is already possible without any changes required. It would work like ntuple inputs, and `cabinetry` stores the histograms created in this step in the location at which it expects them when building a workspace. This step could be made more convenient by allowing custom config properties, which users could then use in their logic to construct paths to their histograms.

#### 4) Extend config schema to support histogram inputs natively
The config could be extended with a set of options that work similar to the [input file path specification for ntuples](https://cabinetry.readthedocs.io/en/latest/core.html#input-file-path-specification). `cabinetry.template_builder.create_histograms` would then read histograms and save the results again in the expected location for workspace building. It would also be possible to skip this step and directly proceed with workspace building, reading histograms from the custom locations instead (see resulting issue below).

### Comparison of approaches
Approaches 1) and 3) are already usable, but could be made more convenient. Approach 4) may be the easiest to use.

Approaches 2) and possibly 4) (depending on implementation) suffer from the potential issue (below) that parsing through inputs to the workspace becomes more difficult, and that it is less clear how to fit in post-processing. The advantage on the other hand is that one step less is required in the workflow.

### Issues with skipping the template creation step in `cabinetry`
When reading histograms from a custom location instead of the place `cabinetry` expects them in for workspace building, `cabinetry.visualize.templates` would have to be rewritten. Furthermore smoothing and other post-processing steps may be more difficult or impossible. When they are not required, it would be faster to read the histogram information from the original source, but since that step in general should be reasonably fast it may be worth keeping the histogram-to-histogram copy step anyway (the equivalent of `cabinetry.template_builder.create_histograms`).
Reading histogram input instead of ntuples
Currently `cabinetry` is designed with columnar data input in mind (e.g. ntuples). Support for histogram input would be useful to also have. A conceptual issue with histograms is that the construction of the histograms already must use information about regions, samples, systematic uncertainties. This information then has to be duplicated in the `cabinetry` config to build a workspace, and duplication of information is not ideal.

### For context: ntuple reading
When reading ntuple inputs, the main steps of the workflow that are relevant to this discussion are the following:
```python
cabinetry.template_builder.create_histograms(config)
cabinetry.template_postprocessor.run(config)
ws = cabinetry.workspace.build(config)
```
The first step identifies all template histograms that have to be built, reads the input columnar data at the locations provided in the config, and stores the resulting histograms in a specific format in the location the user provides in the config. The second step reads these histograms that were just produced, and applies optional post-processing, creating another set of histograms with a fixed naming scheme (in the same user-provided location). The third step uses the histograms in this location to build a workspace (using the post-processed histograms when available, and the original histograms otherwise).

### Possible approaches

#### 1) Users provide histograms in expected format and location
The user puts all histograms in the location `cabinetry` expects, correctly formatted and named. The name is given by [`cabinetry.histo.build_name`](https://github.com/alexander-held/cabinetry/blob/d202b5b5efe78fcfa4a71239de61b70b42f8679b/src/cabinetry/histo.py#L238-L244). A `cabinetry` config is required to run `cabinetry.workspace.build` as a first step of the workflow. Since the config currently requires ntuple-related arguments, those could be filled with placeholders since they are not needed. A better solution would be to make those arguments optional.

#### 2) Users provide a function that points to histograms
Users could provide a function that works similar to [`cabinetry.histo.Histogram.from_config`](https://github.com/alexander-held/cabinetry/blob/d202b5b5efe78fcfa4a71239de61b70b42f8679b/src/cabinetry/histo.py#L92-L123), but loads histograms from wherever they are stored. The mechanisms would probably be similar to [template building overrides](https://cabinetry.readthedocs.io/en/latest/advanced.html#overrides-for-template-building). The template histogram creation step in `cabinetry` would be bypassed (see issue below), possibly leading to some issues with existing functionality (e.g. `cabinetry.visualize.templates` would have to be rewritten).

#### 3) Re-use template building overrides
The existing [template building overrides](https://cabinetry.readthedocs.io/en/latest/advanced.html#overrides-for-template-building) technique could be used to provide a custom function to `cabinetry` that returns histogram information. This is already possible without any changes required. It would work like ntuple inputs, and `cabinetry` stores the histograms created in this step in the location at which it expects them when building a workspace. This step could be made more convenient by allowing custom config properties, which users could then use in their logic to construct paths to their histograms.

#### 4) Extend config schema to support histogram inputs natively
The config could be extended with a set of options that work similar to the [input file path specification for ntuples](https://cabinetry.readthedocs.io/en/latest/core.html#input-file-path-specification). `cabinetry.template_builder.create_histograms` would then read histograms and save the results again in the expected location for workspace building. It would also be possible to skip this step and directly proceed with workspace building, reading histograms from the custom locations instead (see resulting issue below).

### Comparison of approaches
Approaches 1) and 3) are already usable, but could be made more convenient. Approach 4) may be the easiest to use.

Approaches 2) and possibly 4) (depending on implementation) suffer from the potential issue (below) that parsing through inputs to the workspace becomes more difficult, and that it is less clear how to fit in post-processing. The advantage on the other hand is that one step less is required in the workflow.

### Issues with skipping the template creation step in `cabinetry`
When reading histograms from a custom location instead of the place `cabinetry` expects them in for workspace building, `cabinetry.visualize.templates` would have to be rewritten. Furthermore smoothing and other post-processing steps may be more difficult or impossible. When they are not required, it would be faster to read the histogram information from the original source, but since that step in general should be reasonably fast it may be worth keeping the histogram-to-histogram copy step anyway (the equivalent of `cabinetry.template_builder.create_histograms`).


Discussion / hints:
Thanks for pointing out to this issue after the vCHEP talk.

I know that the risk of duplication is high, but I suppose some content will always be duplicated. I suppose signal region optimisations will be done outside of cabinetry. So either one has histograms ready already from this step, or one needs to copy the selection to the configuration here and also setup the input ntuples in a way that cabinetry can process them. I am a bit worried that ntuples -> workspace might take a very long time, which might not be a desired thing.

Slightly related comment: I am newly working on a top measurement and also still keeping in touch with exotics searches and might be interested in running those through cabinetry.
I agree that in practice some duplication may not be easily avoided. When performing something like a signal region optimization, typically the quantity of interest is still something derived from using the workspace. Histograms would then still need to be built one way or another.

One way to minimize duplication could be using a `cabinetry`-like configuration (or something that could easily be converted) to steer the ntuple->histogram step, even if this is run outside of `cabinetry`.
An alternative pattern could be using `cabinetry` to call external tools that efficiently perform ntuple->histogram, possibly customized for individual analysis needs.

The ntuple->histogram handling is missing performance improvements in `cabinetry`. There is some low-hanging fruit that should speed it up quite a bit. One concrete idea is to collect all histogram construction instructions, send them all together to a tool (which does not yet exist), and this tool would then group them together to efficiently process them through e.g. `coffea`. By putting this optimization into some external layer, analyzers could also use it for their own histogram creation code. Another thing that does not yet exist but would help a lot with performance is implementing a caching mechanism to avoid reproducing histograms after a change in the fit model that does not affect them.

The main motivation I am aware of for using histogram inputs to `cabinetry`-like tools is that the tools typically are slower producing the histograms than custom code that can be optimized for the specific use case. Are there other reasons beyond this? I would be interested to find out.

Happy to hear about your interest, please do not hesitate to get in touch if you run into issues!
Moving this up to a target for `v0.4` following a conversation with @gordonwatts and @BenGalewsky. This feature should allow for a demonstrator of using `cabinetry` with data served by `ServiceX` and saved as histograms via `coffea`.
Thanks for pointing out to this issue after the vCHEP talk.

I know that the risk of duplication is high, but I suppose some content will always be duplicated. I suppose signal region optimisations will be done outside of cabinetry. So either one has histograms ready already from this step, or one needs to copy the selection to the configuration here and also setup the input ntuples in a way that cabinetry can process them. I am a bit worried that ntuples -> workspace might take a very long time, which might not be a desired thing.

Slightly related comment: I am newly working on a top measurement and also still keeping in touch with exotics searches and might be interested in running those through cabinetry.
I agree that in practice some duplication may not be easily avoided. When performing something like a signal region optimization, typically the quantity of interest is still something derived from using the workspace. Histograms would then still need to be built one way or another.

One way to minimize duplication could be using a `cabinetry`-like configuration (or something that could easily be converted) to steer the ntuple->histogram step, even if this is run outside of `cabinetry`.
An alternative pattern could be using `cabinetry` to call external tools that efficiently perform ntuple->histogram, possibly customized for individual analysis needs.

The ntuple->histogram handling is missing performance improvements in `cabinetry`. There is some low-hanging fruit that should speed it up quite a bit. One concrete idea is to collect all histogram construction instructions, send them all together to a tool (which does not yet exist), and this tool would then group them together to efficiently process them through e.g. `coffea`. By putting this optimization into some external layer, analyzers could also use it for their own histogram creation code. Another thing that does not yet exist but would help a lot with performance is implementing a caching mechanism to avoid reproducing histograms after a change in the fit model that does not affect them.

The main motivation I am aware of for using histogram inputs to `cabinetry`-like tools is that the tools typically are slower producing the histograms than custom code that can be optimized for the specific use case. Are there other reasons beyond this? I would be interested to find out.

Happy to hear about your interest, please do not hesitate to get in touch if you run into issues!
Moving this up to a target for `v0.4` following a conversation with @gordonwatts and @BenGalewsky. This feature should allow for a demonstrator of using `cabinetry` with data served by `ServiceX` and saved as histograms via `coffea`.

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6486c5f4..5fd95414 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,7 +4,7 @@ repos:
     hooks:
     -   id: black
 -   repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v0.910
+    rev: v0.910-1
     hooks:
     -   id: mypy
         name: mypy with Python 3.7
diff --git a/README.md b/README.md
index 0688c96a..122721b2 100644
--- a/README.md
+++ b/README.md
@@ -47,10 +47,10 @@ import cabinetry
 config = cabinetry.configuration.load("config_example.yml")
 
 # create template histograms
-cabinetry.template_builder.create_histograms(config)
+cabinetry.templates.build(config)
 
 # perform histogram post-processing
-cabinetry.template_postprocessor.run(config)
+cabinetry.templates.postprocess(config)
 
 # build a workspace
 ws = cabinetry.workspace.build(config)
diff --git a/config_example.yml b/config_example.yml
index 847a6554..681daca3 100644
--- a/config_example.yml
+++ b/config_example.yml
@@ -2,7 +2,7 @@ General:
   Measurement: "minimal_example"
   POI: "Signal_norm"
   HistogramFolder: "histograms/"
-  InputPath: "ntuples/{SamplePaths}"
+  InputPath: "inputs/{SamplePath}"
 
 Regions:
   - Name: "Signal_region"
@@ -13,17 +13,17 @@ Regions:
 Samples:
   - Name: "Data"
     Tree: "pseudodata"
-    SamplePaths: "data.root"
+    SamplePath: "data.root"
     Data: True
 
   - Name: "Signal"
     Tree: "signal"
-    SamplePaths: "prediction.root"
+    SamplePath: "prediction.root"
     Weight: "weight"
 
   - Name: "Background"
     Tree: "background"
-    SamplePaths: "prediction.root"
+    SamplePath: "prediction.root"
     Weight: "weight"
 
 Systematics:
@@ -36,7 +36,7 @@ Systematics:
 
   - Name: "Modeling"
     Up:
-      SamplePaths: "prediction.root"
+      SamplePath: "prediction.root"
       Tree: "background_varied"
     Down:
       Symmetrize: True
diff --git a/docs/advanced.rst b/docs/advanced.rst
index c3b53816..d081e6de 100644
--- a/docs/advanced.rst
+++ b/docs/advanced.rst
@@ -60,11 +60,11 @@ When no user-defined function matches a given histogram that has to be produced,
         return hist
 
 
-    cabinetry.template_builder.create_histograms(
+    cabinetry.templates.build(
         cabinetry_config, method="uproot", router=my_router
     )
 
-The instance of ``cabinetry.route.Router`` is handed to ``cabinetry.template_builder.create_histograms`` to enable the use of ``build_data_hist``.
+The instance of ``cabinetry.route.Router`` is handed to ``cabinetry.templates.build`` to enable the use of ``build_data_hist``.
 
 The function ``build_data_hist`` in this example always returns the same histogram.
 Given that the dictionaries in the function signature provide additional information, it is for example possible to return different yields per region:
diff --git a/docs/api.rst b/docs/api.rst
index 1cc8e437..a5bf8eaa 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -27,17 +27,34 @@ cabinetry.histo
     :members:
 
 
-cabinetry.template_builder
---------------------------
+cabinetry.templates
+-------------------
+
+.. automodule:: cabinetry.templates
+    :members:
+
+cabinetry.templates.builder
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. automodule:: cabinetry.templates.builder
+    :members:
 
-.. automodule:: cabinetry.template_builder
+cabinetry.templates.collector
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. automodule:: cabinetry.templates.collector
     :members:
 
+cabinetry.templates.postprocessor
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. automodule:: cabinetry.templates.postprocessor
+    :members:
 
-cabinetry.template_postprocessor
---------------------------------
+cabinetry.templates.utils
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-.. automodule:: cabinetry.template_postprocessor
+.. automodule:: cabinetry.templates.utils
     :members:
 
 
@@ -112,8 +129,14 @@ cabinetry.contrib
 
 .. automodule:: cabinetry.contrib
 
-cabinetry.contrib.histogram_creation
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+cabinetry.contrib.histogram_creator
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. automodule:: cabinetry.contrib.histogram_creator
+    :members:
+
+cabinetry.contrib.histogram_reader
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-.. automodule:: cabinetry.contrib.histogram_creation
+.. automodule:: cabinetry.contrib.histogram_reader
     :members:
diff --git a/docs/config.rst b/docs/config.rst
index d8490ae3..c0259011 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -1,3 +1,5 @@
+..  _config:
+
 Configuration schema
 ====================
 
@@ -34,7 +36,7 @@ Common options:
 
 .. jsonschema:: ../src/cabinetry/schemas/config.json#/definitions/samples_setting
 
-.. jsonschema:: ../src/cabinetry/schemas/config.json#/definitions/samplepaths_setting
+.. jsonschema:: ../src/cabinetry/schemas/config.json#/definitions/samplepath_setting
 
 .. jsonschema:: ../src/cabinetry/schemas/config.json#/definitions/regions_setting
 
diff --git a/docs/core.rst b/docs/core.rst
index 64916142..5590b078 100644
--- a/docs/core.rst
+++ b/docs/core.rst
@@ -1,44 +1,52 @@
 Core concepts
 =============
 
-Input file path specification
------------------------------
+Inputs to cabinetry: ntuples or histograms
+------------------------------------------
 
-Paths to input files for histogram production are specified with the mandatory ``InputPath`` setting in the ``General`` config section.
+``cabinetry`` supports two types of input files when building a workspace: ntuples containing columnar data and histograms.
+When using ntuple inputs, ``cabinetry`` needs to know not only where to find the input files for every template histogram it needs to build, but also what selections to apply, which column to extract and how to weight every event.
+The configuration schema lists the required options, see :ref:`config` for more information.
+Less information is required when using histogram inputs: only the path to each histogram needs to be specified in this case.
+
+Input file path specification for ntuples
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Paths to ntuple input files for histogram production are specified with the mandatory ``InputPath`` setting in the ``General`` config section.
 If everything is in one file, the value should be the path to this file.
 It is common to have multiple input files, split across phase space regions or samples.
-For this purpose, the ``InputPath`` value can take two placeholders: ``{RegionPath}`` and ``{SamplePaths}``.
+For this purpose, the ``InputPath`` value can take two placeholders: ``{RegionPath}`` and ``{SamplePath}``.
 
 RegionPath
-^^^^^^^^^^
+""""""""""
 
 When building histograms for a specific region, the ``{RegionPath}`` placeholder takes the value specified in the ``RegionPath`` setting of the corresponding region.
 The value of ``RegionPath`` has to be a string.
 
-SamplePaths
-^^^^^^^^^^^
+SamplePath
+""""""""""
 
-The ``{SamplePaths}`` placeholder takes the value given by ``SamplePaths`` of the sample currently processed.
+The ``{SamplePath}`` placeholder takes the value given by ``SamplePath`` of the sample currently processed.
 This value can either be a string or a list of strings.
-If it is a list, multiple copies of ``InputPath`` are created, and in each of them the ``{SamplePaths}`` placeholder takes the value of a different entry in the list.
+If it is a list, multiple copies of ``InputPath`` are created, and in each of them the ``{SamplePath}`` placeholder takes the value of a different entry in the list.
 All input files are processed, and their contributions are summed together.
-The histogram created by ``SamplePaths: ["a.root", "b.root"]`` is equivalent to the histogram created with ``SamplePaths: "a_plus_b.root"``, where ``a_plus_b.root`` is produced by merging both files.
+The histogram created by ``SamplePath: ["a.root", "b.root"]`` is equivalent to the histogram created with ``SamplePath: "a_plus_b.root"``, where ``a_plus_b.root`` is produced by merging both files.
 
 Systematics
-^^^^^^^^^^^
+"""""""""""
 
-It is possible to specify overrides for the ``RegionPath`` and ``SamplePaths`` values in systematic templates.
+It is possible to specify overrides for the ``RegionPath`` and ``SamplePath`` values in systematic templates.
 If those settings are specified in the ``Up`` or ``Down`` template section of a systematic uncertainty, then the corresponding values are used when building the path to the file used to construct the histogram for this specific template.
 
 An example
-^^^^^^^^^^
+""""""""""
 
 The following configuration file excerpt shows an example of specifying paths to input files.
 
 .. code-block:: yaml
 
     General:
-      InputPath: "ntuples/{RegionPath}/{SamplePaths}"
+      InputPath: "inputs/{RegionPath}/{SamplePath}"
 
     Regions:
       - Name: "Signal_region"
@@ -49,37 +57,124 @@ The following configuration file excerpt shows an example of specifying paths to
 
     Samples:
       - Name: "Data"
-        SamplePaths: "data.root"
+        SamplePath: "data.root"
 
       - Name: "Signal"
-        SamplePaths: ["signal_1.root", "signal_2.root"]
+        SamplePath: ["signal_1.root", "signal_2.root"]
 
     Systematics:
       - Name: "Signal_modeling"
         Up:
-          SamplePaths: "signal_variation_up.root"
+          SamplePath: "modeling_variation_up.root"
         Down:
-          SamplePaths: "signal_variation_down.root"
+          SamplePath: "modeling_variation_down.root"
         Samples: "Signal"
 
 The following files will be read to create histograms:
 
 - for *Signal_region*:
 
-    - *Data*: ``ntuples/signal_region/data.root``
-    - *Signal*: ``ntuples/signal_region/signal_1.root``, ``ntuples/signal_region/signal_2.root``
+    - *Data*: ``inputs/signal_region/data.root``
+    - *Signal*: ``inputs/signal_region/signal_1.root``, ``inputs/signal_region/signal_2.root``
+
+        - systematic uncertainty:
+
+            - *up*: ``inputs/signal_region/modeling_variation_up.root``
+            - *down*: ``inputs/signal_region/modeling_variation_down.root``
+
+- for *Control_region*:
+
+    - *Data*: ``inputs/control_region/data.root``
+    - *Signal*: ``inputs/control_region/signal_1.root``, ``inputs/control_region/signal_2.root``
+
+        - systematic uncertainty:
+
+            - *up*: ``inputs/control_region/modeling_variation_up.root``
+            - *down*: ``inputs/control_region/modeling_variation_down.root``
+
+Input file path specification for histograms
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The specification of paths to histograms works very similarly to the ntuple case.
+The ``InputPath`` setting in the ``General`` config section is still mandatory.
+It can again take placeholders: ``{RegionPath}``, ``{SamplePath}``, and ``{VariationPath}``.
+The ``VariationPath`` setting will default to an empty string if not specified, but it can be set to another value (such as ``"nominal"``) in the ``General`` block.
+
+A major difference to the ntuple path construction is that the histogram path needs to not only include the path to the file containing a given histogram, but also to the histogram within the file.
+This is achieved by using a colon ``:`` to distinguish between both parts of the path: ``folder/file.root:abc/h1`` points to a histogram called ``h1`` located in a folder called ``abc`` which itself exists within a file called ``file.root`` which can be found in a folder called ``folder``.
+
+When using histogram inputs, use ``cabinetry.templates.collect`` instead of ``cabinetry.templates.build`` (which is used for ntuple inputs).
+
+RegionPath
+""""""""""
+
+This works in the same way as it does for ntuples: the ``RegionPath`` setting in each region sets the value for the ``{RegionPath}`` placeholder.
+Note that the value cannot be overridden on a per-systematic basis in the histogram case.
+
+SamplePath
+""""""""""
+
+The ``SamplePath`` setting sets the value for the ``{SamplePath}`` placeholder.
+In contrast to the ntuple case, this value cannot be a list of strings.
+It also cannot be overridden on a per-systematic basis, just like ``RegionPath``.
+
+VariationPath
+"""""""""""""
+
+Each systematic template can set the value for the ``{VariationPath}`` placeholder via the ``VariationPath`` setting.
+``RegionPath`` and ``SamplePath`` settings cannot be overridden.
+
+An example
+""""""""""
+
+The following shows an example, similar to the ntuple example.
+
+.. code-block:: yaml
+
+    General:
+      InputPath: "inputs/{RegionPath}.root:{SamplePath}_{VariationPath}"
+      VariationPath: "nominal"
+
+    Regions:
+      - Name: "Signal_region"
+        RegionPath: "signal_region"
+
+      - Name: "Control_region"
+        RegionPath: "control_region"
+
+    Samples:
+      - Name: "Data"
+        SamplePath: "data"
+
+      - Name: "Signal"
+        SamplePath: "signal"
+
+    Systematics:
+      - Name: "Signal_modeling"
+        Up:
+          VariationPath: "modeling_variation_up"
+        Down:
+          VariationPath: "modeling_variation_down"
+        Samples: "Signal"
+
+The following histograms will be read:
+
+- for *Signal_region*:
+
+    - *Data*: ``inputs/signal_region.root:data_nominal``
+    - *Signal*: ``inputs/signal_region.root:signal_nominal``
 
         - systematic uncertainty:
 
-            - *up*: ``ntuples/signal_region/signal_variation_up.root``
-            - *down*: ``ntuples/signal_region/signal_variation_down.root``
+            - *up*: ``inputs/signal_region.root:signal_modeling_variation_up``
+            - *down*: ``inputs/signal_region:signal_modeling_variation_down``
 
 - for *Control_region*:
 
-    - *Data*: ``ntuples/control_region/data.root``
-    - *Signal*: ``ntuples/control_region/signal_1.root``, ``ntuples/control_region/signal_2.root``
+    - *Data*: ``inputs/control_region.root:data_nominal``
+    - *Signal*: ``inputs/control_region.root:signal_nominal``
 
         - systematic uncertainty:
 
-            - *up*: ``ntuples/control_region/signal_variation_up.root``
-            - *down*: ``ntuples/control_region/signal_variation_down.root``
+            - *up*: ``inputs/control_region.root:signal_modeling_variation_up``
+            - *down*: ``inputs/control_region:signal_modeling_variation_down``
diff --git a/example.py b/example.py
index 4e1441c3..aa0aac4e 100644
--- a/example.py
+++ b/example.py
@@ -8,7 +8,7 @@
     cabinetry.set_logging()
 
     # check whether input data exists
-    if not os.path.exists("ntuples/"):
+    if not os.path.exists("inputs/"):
         print("run utils/create_ntuples.py to create input data")
         raise SystemExit
 
@@ -17,12 +17,12 @@
     cabinetry.configuration.print_overview(config)
 
     # create template histograms
-    cabinetry.template_builder.create_histograms(config, method="uproot")
+    cabinetry.templates.build(config, method="uproot")
 
     # perform histogram post-processing
-    cabinetry.template_postprocessor.run(config)
+    cabinetry.templates.postprocess(config)
 
-    # visualize systematics templates
+    # visualize systematic templates
     cabinetry.visualize.templates(config)
 
     # build a workspace and save to file
diff --git a/src/cabinetry/__init__.py b/src/cabinetry/__init__.py
index 72bf1088..d58b54cc 100644
--- a/src/cabinetry/__init__.py
+++ b/src/cabinetry/__init__.py
@@ -6,8 +6,7 @@
 import cabinetry.route  # noqa: F401
 import cabinetry.smooth  # noqa: F401
 import cabinetry.tabulate  # noqa: F401
-import cabinetry.template_builder  # noqa: F401
-import cabinetry.template_postprocessor  # noqa: F401
+import cabinetry.templates  # noqa: F401
 import cabinetry.visualize  # noqa: F401
 import cabinetry.workspace  # noqa: F401
 
diff --git a/src/cabinetry/cli/__init__.py b/src/cabinetry/cli/__init__.py
index 63f1d977..1aa4ed7e 100644
--- a/src/cabinetry/cli/__init__.py
+++ b/src/cabinetry/cli/__init__.py
@@ -10,8 +10,7 @@
 from cabinetry import configuration as cabinetry_configuration
 from cabinetry import fit as cabinetry_fit
 from cabinetry import model_utils as cabinetry_model_utils
-from cabinetry import template_builder as cabinetry_template_builder
-from cabinetry import template_postprocessor as cabinetry_template_postprocessor
+from cabinetry import templates as cabinetry_templates
 from cabinetry import visualize as cabinetry_visualize
 from cabinetry import workspace as cabinetry_workspace
 
@@ -51,7 +50,7 @@ def templates(config: io.TextIOWrapper, method: str) -> None:
     _set_logging()
     cabinetry_config = yaml.safe_load(config)
     cabinetry_configuration.validate(cabinetry_config)
-    cabinetry_template_builder.create_histograms(cabinetry_config, method=method)
+    cabinetry_templates.build(cabinetry_config, method=method)
 
 
 @click.command()
@@ -64,7 +63,7 @@ def postprocess(config: io.TextIOWrapper) -> None:
     _set_logging()
     cabinetry_config = yaml.safe_load(config)
     cabinetry_configuration.validate(cabinetry_config)
-    cabinetry_template_postprocessor.run(cabinetry_config)
+    cabinetry_templates.postprocess(cabinetry_config)
 
 
 @click.command()
diff --git a/src/cabinetry/contrib/histogram_creation.py b/src/cabinetry/contrib/histogram_creator.py
similarity index 97%
rename from src/cabinetry/contrib/histogram_creation.py
rename to src/cabinetry/contrib/histogram_creator.py
index 3dc75420..eaeffeb8 100644
--- a/src/cabinetry/contrib/histogram_creation.py
+++ b/src/cabinetry/contrib/histogram_creator.py
@@ -1,3 +1,5 @@
+"""Creates histograms from ntuples with uproot."""
+
 import pathlib
 from typing import List, Optional
 
@@ -7,7 +9,7 @@
 import uproot
 
 
-def from_uproot(
+def with_uproot(
     ntuple_paths: List[pathlib.Path],
     pos_in_file: str,
     variable: str,
diff --git a/src/cabinetry/contrib/histogram_reader.py b/src/cabinetry/contrib/histogram_reader.py
new file mode 100644
index 00000000..aaf4fab0
--- /dev/null
+++ b/src/cabinetry/contrib/histogram_reader.py
@@ -0,0 +1,18 @@
+"""Reads histograms with uproot."""
+
+import boost_histogram as bh
+import uproot
+
+
+def with_uproot(histo_path: str) -> bh.Histogram:
+    """Reads a histogram with uproot and returns it.
+
+    Args:
+        histo_path (str): path to histogram, use a colon to distinguish between path to
+            file and path to histogram within file (example: ``file.root:h1``)
+
+    Returns:
+        bh.Histogram: histogram containing data
+    """
+    hist = uproot.open(histo_path).to_boost()
+    return hist
diff --git a/src/cabinetry/model_utils.py b/src/cabinetry/model_utils.py
index b9ed58b1..f4aa5590 100644
--- a/src/cabinetry/model_utils.py
+++ b/src/cabinetry/model_utils.py
@@ -494,9 +494,9 @@ def match_fit_results(model: pyhf.pdf.Model, fit_results: FitResults) -> FitResu
     If the fit results contain parameters missing in the model, these parameters are not
     included in the returned fit results. If the fit results do not include parameters
     used in the model, they are added to the fit results. The best-fit value for such
-    parameters are the Asimov values as returned by ```asimov_parameters``` (initial
+    parameters are the Asimov values as returned by ``asimov_parameters`` (initial
     parameter settings for unconstrained parameters), and the associated uncertainties
-    as given by ```prefit_uncertainties``` (zero uncertainty for unconstrained or fixed
+    as given by ``prefit_uncertainties`` (zero uncertainty for unconstrained or fixed
     parameters). These parameters furthermore are assumed to have no correlation with
     any other parameters. If required, parameters are re-ordered to match the target
     model.
diff --git a/src/cabinetry/schemas/config.json b/src/cabinetry/schemas/config.json
index 0749a5da..fcc11a9c 100644
--- a/src/cabinetry/schemas/config.json
+++ b/src/cabinetry/schemas/config.json
@@ -7,10 +7,12 @@
     "required": ["General", "Regions", "Samples", "NormFactors"],
     "properties": {
         "General": {
+            "title": "General",
             "description": "general settings",
             "$ref": "#/definitions/general"
         },
         "Regions": {
+            "title": "Regions",
             "description": "list of regions",
             "type": "array",
             "minItems": 1,
@@ -21,6 +23,7 @@
             "uniqueItems": true
         },
         "Samples": {
+            "title": "Samples",
             "description": "list of samples",
             "type": "array",
             "minItems": 1,
@@ -31,6 +34,7 @@
             "uniqueItems": true
         },
         "NormFactors": {
+            "title": "NormFactors",
             "description": "list of normalization factors",
             "type": "array",
             "minItems": 1,
@@ -41,6 +45,7 @@
             "uniqueItems": true
         },
         "Systematics": {
+            "title": "Systematics",
             "description": "list of systematics",
             "type": "array",
             "minItems": 0,
@@ -53,7 +58,7 @@
     },
     "definitions": {
         "general": {
-            "title": "General",
+            "title": "General settings",
             "$$target": "#/definitions/general",
             "description": "general settings",
             "type": "object",
@@ -68,7 +73,7 @@
                     "type": "string"
                 },
                 "InputPath": {
-                    "description": "path to input ntuples",
+                    "description": "path to input files",
                     "type": "string"
                 },
                 "HistogramFolder": {
@@ -96,6 +101,10 @@
                         "additionalProperties": false
                     },
                     "uniqueItems": true
+                },
+                "VariationPath": {
+                    "description": "(part of) path to file containing variation (for histogram inputs), defaults to empty string",
+                    "type": "string"
                 }
             },
             "additionalProperties": false
@@ -104,8 +113,19 @@
             "title": "Region",
             "$$target": "#/definitions/region",
             "description": "a region of phase space",
+            "$$description": [
+                "required: Name + Variable + Binning (for ntuple inputs),",
+                "Name + RegionPath (for histogram inputs)"
+            ],
             "type": "object",
-            "required": ["Name", "Variable", "Binning"],
+            "anyOf": [
+                {
+                    "required": ["Name", "Variable", "Binning"]
+                },
+                {
+                    "required": ["Name", "RegionPath"]
+                }
+            ],
             "properties": {
                 "Name": {
                     "description": "name of the region",
@@ -140,8 +160,19 @@
             "title": "Sample",
             "$$target": "#/definitions/sample",
             "description": "a sample of a specific process or data",
+            "$$description": [
+                "required: Name + Tree (for ntuple inputs),",
+                "Name + SamplePath (for histogram inputs)"
+            ],
             "type": "object",
-            "required": ["Name", "Tree"],
+            "anyOf": [
+                {
+                    "required": ["Name", "Tree"]
+                },
+                {
+                    "required": ["Name", "SamplePath"]
+                }
+            ],
             "properties": {
                 "Name": {
                     "description": "name of the sample",
@@ -159,9 +190,9 @@
                     "description": "weight to apply to events",
                     "type": "string"
                 },
-                "SamplePaths": {
+                "SamplePath": {
                     "description": "(part of) path(s) to input file(s)",
-                    "$ref": "#/definitions/samplepaths_setting"
+                    "$ref": "#/definitions/samplepath_setting"
                 },
                 "Data": {
                     "description": "if it is a data sample",
@@ -257,7 +288,7 @@
         "template_setting": {
             "title": "Template",
             "$$target": "#/definitions/template_setting",
-            "description": "a systematics template (up/down)",
+            "description": "a systematic template (up/down)",
             "type": "object",
             "properties": {
                 "Tree": {
@@ -280,9 +311,9 @@
                     "description": "(part of) path to file containing region (override for nominal setting)",
                     "type": "string"
                 },
-                "SamplePaths": {
+                "SamplePath": {
                     "description": "(part of) path(s) to input file(s) (override for nominal setting)",
-                    "$ref": "#/definitions/samplepaths_setting"
+                    "$ref": "#/definitions/samplepath_setting"
                 },
                 "Normalization": {
                     "description": "normalization uncertainty to apply",
@@ -291,6 +322,10 @@
                 "Symmetrize": {
                     "description": "whether to apply symmetrization",
                     "type": "boolean"
+                },
+                "VariationPath": {
+                    "description": "(part of) path to file containing variation (for histogram inputs, override for general setting)",
+                    "type": "string"
                 }
             },
             "additionalProperties": false
@@ -316,10 +351,11 @@
                 }
             ]
         },
-        "samplepaths_setting": {
-            "title": "SamplePaths setting",
-            "$$target": "#/definitions/samplepaths_setting",
-            "description": "path(s) to input file(s) for ntuple production",
+        "samplepath_setting": {
+            "title": "SamplePath setting",
+            "$$target": "#/definitions/samplepath_setting",
+            "description": "path(s) to input file(s) for histogram production",
+            "$$description": "a list of paths is only supported for ntuple inputs",
             "oneOf": [
                 {
                     "description": "path to single file",
diff --git a/src/cabinetry/templates/__init__.py b/src/cabinetry/templates/__init__.py
new file mode 100644
index 00000000..c8040e26
--- /dev/null
+++ b/src/cabinetry/templates/__init__.py
@@ -0,0 +1,84 @@
+"""High-level entry point to create, collect and post-process template histograms."""
+
+import logging
+import pathlib
+from typing import Any, Dict, Optional
+
+from cabinetry import route
+from cabinetry.templates import builder
+from cabinetry.templates import collector
+from cabinetry.templates import postprocessor
+
+
+log = logging.getLogger(__name__)
+
+
+def build(
+    config: Dict[str, Any],
+    method: str = "uproot",
+    router: Optional[route.Router] = None,
+) -> None:
+    """Produces all required histograms specified by the configuration file.
+
+    Inputs to the histogram production are ntuples containing columnar data. Uses either
+    a default method specified via ``method``, or a custom user-defined override through
+    ``router``.
+
+    Args:
+        config (Dict[str, Any]): cabinetry configuration
+        method (str, optional): backend to use for histogram production, defaults to
+            "uproot"
+        router (Optional[route.Router], optional): instance of cabinetry.route.Router
+            that contains user-defined overrides, defaults to None
+    """
+    # create an instance of the class doing the template building
+    histogram_folder = pathlib.Path(config["General"]["HistogramFolder"])
+    general_path = config["General"]["InputPath"]
+    template_builder = builder._Builder(histogram_folder, general_path, method)
+
+    match_func: Optional[route.MatchFunc] = None
+    if router is not None:
+        # specify the wrapper for user-defined functions
+        router.template_builder_wrapper = template_builder._wrap_custom_template_builder
+        # get a function that can be queried to return a user-defined template builder
+        match_func = router._find_template_builder_match
+
+    route.apply_to_all_templates(
+        config, template_builder._create_histogram, match_func=match_func
+    )
+
+
+def collect(config: Dict[str, Any], method: str = "uproot") -> None:
+    """Collects all required histograms specified by the configuration file.
+
+    Histograms must already exist, and this collects and saves them in the format used
+    for further processing. If no default for VariationPath is specified in the general
+    settings, it defaults to an empty string.
+
+    Args:
+        config (Dict[str, Any]): cabinetry configuration
+        method (str, optional): backend to use for histogram production, defaults to
+            "uproot"
+    """
+    histogram_folder = pathlib.Path(config["General"]["HistogramFolder"])
+    general_path = config["General"]["InputPath"]
+    variation_path = config["General"].get("VariationPath", None)
+    if variation_path is None:
+        # default to empty string and emit warning
+        variation_path = ""
+        log.info('no VariationPath specified in general settings, defaulting to ""')
+    processor = collector._collector(
+        histogram_folder, general_path, variation_path, method
+    )
+    route.apply_to_all_templates(config, processor)
+
+
+def postprocess(config: Dict[str, Any]) -> None:
+    """Applies postprocessing to all histograms.
+
+    Args:
+        config (Dict[str, Any]): cabinetry configuration
+    """
+    histogram_folder = pathlib.Path(config["General"]["HistogramFolder"])
+    processor = postprocessor._postprocessor(histogram_folder)
+    route.apply_to_all_templates(config, processor)
diff --git a/src/cabinetry/template_builder.py b/src/cabinetry/templates/builder.py
similarity index 71%
rename from src/cabinetry/template_builder.py
rename to src/cabinetry/templates/builder.py
index d9fcdb53..3c848e7d 100644
--- a/src/cabinetry/template_builder.py
+++ b/src/cabinetry/templates/builder.py
@@ -1,7 +1,9 @@
+"""Creates required template histograms from columnar data."""
+
 import functools
 import logging
 import pathlib
-from typing import Any, Dict, List, Optional, Union
+from typing import Any, Dict, List, Optional
 
 import boost_histogram as bh
 import numpy as np
@@ -10,32 +12,12 @@
 from cabinetry import histo
 from cabinetry import route
 from cabinetry._typing import Literal
+from cabinetry.templates import utils
 
 
 log = logging.getLogger(__name__)
 
 
-def _check_for_override(
-    systematic: Dict[str, Any], template: Literal["Up", "Down"], option: str
-) -> Optional[Union[str, List[str]]]:
-    """Returns an override if specified by a template of a systematic.
-
-    Given a systematic and a string specifying which template is currently under
-    consideration, check whether the systematic defines an override for an option.
-    Return the override if it exists, otherwise return None.
-
-    Args:
-        systematic (Dict[str, Any]): containing all systematic information
-        template (Literal["Up", "Down"]): template considered: "Up" or "Down"
-        option (str): the option for which the presence of an override is checked
-
-    Returns:
-        Optional[Union[str, List[str]]]: either None if no override exists, or the
-        override
-    """
-    return systematic.get(template, {}).get(option, None)
-
-
 def _ntuple_paths(
     general_path: str,
     region: Dict[str, Any],
@@ -48,35 +30,41 @@ def _ntuple_paths(
     A path is built starting from the path specified in the general options in the
     configuration file. This path can contain placeholders for region- and sample-
     specific overrides, via ``{Region}`` and ``{Sample}``. For non-nominal templates, it
-    is possible to override the sample path if the ``SamplePaths`` option is specified
-    for the template. If ``SamplePaths`` is a list, return a list of paths (one per
+    is possible to override the sample path if the ``SamplePath`` option is specified
+    for the template. If ``SamplePath`` is a list, return a list of paths (one per
     entry in the list).
 
     Args:
         general_path (str): path specified in general settings, with sections that can
-            be overridden by region / sample settings
+            be overridden by region / sample / systematic settings
         region (Dict[str, Any]): containing all region information
         sample (Dict[str, Any]): containing all sample information
         systematic (Dict[str, Any]): containing all systematic information
         template (Optional[Literal["Up", "Down"]]): template considered: "Up", "Down",
             or None for nominal
 
+    Raises:
+        ValueError: when ``RegionPath`` placeholder is used, but region setting is not
+            specified
+        ValueError: when ``SamplePath`` placeholder is used, but sample setting is not
+            specified
+
     Returns:
         List[pathlib.Path]: list of paths to ntuples
     """
     # obtain region and sample paths, if they are defined
     region_path = region.get("RegionPath", None)
-    sample_paths = sample.get("SamplePaths", None)
+    sample_paths = sample.get("SamplePath", None)
 
     # check whether a systematic is being processed, and whether overrides exist
     if template is not None:
         # determine whether the template has an override for RegionPath specified
-        region_override = _check_for_override(systematic, template, "RegionPath")
+        region_override = utils._check_for_override(systematic, template, "RegionPath")
         if region_override is not None:
             region_path = region_override
 
-        # check for SamplePaths override
-        sample_override = _check_for_override(systematic, template, "SamplePaths")
+        # check for SamplePath override
+        sample_override = utils._check_for_override(systematic, template, "SamplePath")
         if sample_override is not None:
             sample_paths = sample_override
 
@@ -90,21 +78,21 @@ def _ntuple_paths(
     elif region_template_exists:
         raise ValueError(f"no path setting found for region {region['Name']}")
 
-    sample_template_exists = "{SamplePaths}" in general_path
+    sample_template_exists = "{SamplePath}" in general_path
     if sample_paths is not None:
         if not sample_template_exists:
             log.warning(
-                "sample override specified, but {SamplePaths} not found in default path"
+                "sample override specified, but {SamplePath} not found in default path"
             )
-        # SamplePaths can be a list, so need to construct all possible paths
+        # SamplePath can be a list, so need to construct all possible paths
         sample_paths = configuration._setting_to_list(sample_paths)
         path_list = []
         for sample_path in sample_paths:
-            path_list.append(general_path.replace("{SamplePaths}", sample_path))
+            path_list.append(general_path.replace("{SamplePath}", sample_path))
     elif sample_template_exists:
         raise ValueError(f"no path setting found for sample {sample['Name']}")
     else:
-        # no need for multiple paths, and no SamplePaths are present, so turn
+        # no need for multiple paths, and no SamplePath are present, so turn
         # the existing path into a list
         path_list = [general_path]
 
@@ -138,7 +126,9 @@ def _variable(
     # check whether a systematic is being processed
     if template is not None:
         # determine whether the template has an override specified
-        axis_variable_override = _check_for_override(systematic, template, "Variable")
+        axis_variable_override = utils._check_for_override(
+            systematic, template, "Variable"
+        )
         if axis_variable_override is not None:
             axis_variable = axis_variable_override
     return axis_variable
@@ -177,7 +167,9 @@ def _filter(
     # check whether a systematic is being processed
     if template is not None:
         # determine whether the template has an override specified
-        selection_filter_override = _check_for_override(systematic, template, "Filter")
+        selection_filter_override = utils._check_for_override(
+            systematic, template, "Filter"
+        )
         if selection_filter_override is not None:
             selection_filter = selection_filter_override
     return selection_filter
@@ -209,7 +201,7 @@ def _weight(
     # check whether a systematic is being processed
     if template is not None:
         # determine whether the template has an override specified
-        weight_override = _check_for_override(systematic, template, "Weight")
+        weight_override = utils._check_for_override(systematic, template, "Weight")
         if weight_override is not None:
             weight = weight_override
     return weight
@@ -238,7 +230,7 @@ def _position_in_file(
     # check whether a systematic is being processed
     if template is not None:
         # determine whether the template has an override specified
-        position_override = _check_for_override(systematic, template, "Tree")
+        position_override = utils._check_for_override(systematic, template, "Tree")
         if position_override is not None:
             position = position_override
     return position
@@ -315,9 +307,9 @@ def _create_histogram(
 
         # obtain the histogram
         if self.method == "uproot":
-            from cabinetry.contrib import histogram_creation
+            from cabinetry.contrib import histogram_creator
 
-            histogram = histogram_creation.from_uproot(
+            histogram = histogram_creator.with_uproot(
                 ntuple_paths,
                 pos_in_file,
                 variable,
@@ -330,38 +322,15 @@ def _create_histogram(
             raise NotImplementedError(f"unknown backend {self.method}")
 
         # store information in a Histogram instance and save it
-        self._name_and_save(
-            histo.Histogram(histogram), region, sample, systematic, template
+        utils._name_and_save(
+            self.histogram_folder,
+            histo.Histogram(histogram),
+            region,
+            sample,
+            systematic,
+            template,
         )
 
-    def _name_and_save(
-        self,
-        histogram: histo.Histogram,
-        region: Dict[str, Any],
-        sample: Dict[str, Any],
-        systematic: Dict[str, Any],
-        template: Optional[Literal["Up", "Down"]],
-    ) -> None:
-        """Generates a unique name for a histogram and saves the histogram.
-
-        Args:
-            histogram (histo.Histogram): histogram to save
-            region (Dict[str, Any]): containing all region information
-            sample (Dict[str, Any]): containing all sample information
-            systematic (Dict[str, Any]): containing all systematic
-            template (Optional[Literal["Up", "Down"]]): template considered: "Up",
-                "Down", or None for nominal
-        """
-        # generate a name for the histogram
-        histogram_name = histo.name(region, sample, systematic, template)
-
-        # check the histogram for common issues
-        histogram.validate(histogram_name)
-
-        # save it
-        histo_path = self.histogram_folder / histogram_name
-        histogram.save(histo_path)
-
     def _wrap_custom_template_builder(
         self, func: route.UserTemplateFunc
     ) -> route.ProcessorFunc:
@@ -403,42 +372,13 @@ def wrapper(
                 raise TypeError(
                     f"{func.__name__} must return a boost_histogram.Histogram"
                 )
-            self._name_and_save(
-                histo.Histogram(histogram), region, sample, systematic, template
+            utils._name_and_save(
+                self.histogram_folder,
+                histo.Histogram(histogram),
+                region,
+                sample,
+                systematic,
+                template,
             )
 
         return wrapper
-
-
-def create_histograms(
-    config: Dict[str, Any],
-    method: str = "uproot",
-    router: Optional[route.Router] = None,
-) -> None:
-    """Produces all required histograms specified by the configuration file.
-
-    Uses either a default method specified via ``method``, or a custom user-defined
-    override through ``router``.
-
-    Args:
-        config (Dict[str, Any]): cabinetry configuration
-        method (str, optional): backend to use for histogram production, defaults to
-            "uproot"
-        router (Optional[route.Router], optional): instance of cabinetry.route.Router
-            that contains user-defined overrides, defaults to None
-    """
-    # create an instance of the class doing the template building
-    histogram_folder = pathlib.Path(config["General"]["HistogramFolder"])
-    general_path = config["General"]["InputPath"]
-    builder = _Builder(histogram_folder, general_path, method)
-
-    match_func: Optional[route.MatchFunc] = None
-    if router is not None:
-        # specify the wrapper for user-defined functions
-        router.template_builder_wrapper = builder._wrap_custom_template_builder
-        # get a function that can be queried to return a user-defined template builder
-        match_func = router._find_template_builder_match
-
-    route.apply_to_all_templates(
-        config, builder._create_histogram, match_func=match_func
-    )
diff --git a/src/cabinetry/templates/collector.py b/src/cabinetry/templates/collector.py
new file mode 100644
index 00000000..582ceebe
--- /dev/null
+++ b/src/cabinetry/templates/collector.py
@@ -0,0 +1,162 @@
+"""Collects required template histograms provided by user."""
+
+import logging
+import pathlib
+from typing import Any, cast, Dict, Optional
+
+from cabinetry import histo
+from cabinetry import route
+from cabinetry._typing import Literal
+from cabinetry.templates import utils
+
+
+log = logging.getLogger(__name__)
+
+
+def _histo_path(
+    general_path: str,
+    variation_path: str,
+    region: Dict[str, Any],
+    sample: Dict[str, Any],
+    systematic: Dict[str, Any],
+    template: Optional[Literal["Up", "Down"]],
+) -> str:
+    """Returns the paths to a histogram for a region-sample-systematic-template.
+
+    The path should contain a colon to separate the path to the file itself and the
+    location of the histogram within the file. Due to the presence of this colon, the
+    return value is a string instead of a pathlib path. A path is built starting from
+    the path specified in the general options in the configuration file. This path
+    contains placeholders for region-, sample-, and systematic-specific values.
+
+    Args:
+        general_path (str): path specified in general settings, with sections that can
+            be overridden by region / sample / systematic settings
+        variation_path (str): default for VariationPath setting
+        region (Dict[str, Any]): containing all region information
+        sample (Dict[str, Any]): containing all sample information
+        systematic (Dict[str, Any]): containing all systematic information
+        template (Optional[Literal["Up", "Down"]]): template considered: "Up", "Down",
+            or None for nominal
+
+    Raises:
+        ValueError: when ``RegionPath`` placeholder is used, but region setting is not
+            specified
+        ValueError: when ``SamplePath`` placeholder is used, but sample setting is not
+            specified
+
+    Returns:
+        str: path to input file (and histogram within)
+    """
+    # obtain region and sample paths, if they are defined
+    region_path = region.get("RegionPath", None)
+    sample_path = sample.get("SamplePath", None)
+
+    # check whether a systematic is being processed
+    if template is not None:
+        # determine whether the template has an override for VariationPath specified
+        variation_override = utils._check_for_override(
+            systematic, template, "VariationPath"
+        )
+        if variation_override is not None:
+            # _check_for_override should return Optional[str] for VariationPath, but
+            # mypy cannot know that it will not be a list, so explicitly cast to str
+            variation_path = cast(str, variation_override)
+        else:
+            log.warning(
+                f"no VariationPath override specified for {region['Name']} / "
+                f"{sample['Name']} / {systematic['Name']} {template}"
+            )
+    # apply variation-specific setting
+    path = general_path.replace("{VariationPath}", variation_path)
+
+    # handle region-specific setting
+    region_template_exists = "{RegionPath}" in general_path
+    if region_path is not None:
+        if not region_template_exists:
+            log.warning(
+                "region override specified, but {RegionPath} not found in default path"
+            )
+        path = path.replace("{RegionPath}", region_path)
+    elif region_template_exists:
+        raise ValueError(f"no path setting found for region {region['Name']}")
+
+    # handle sample-specific setting
+    sample_template_exists = "{SamplePath}" in general_path
+    if sample_path is not None:
+        if not sample_template_exists:
+            log.warning(
+                "sample override specified, but {SamplePath} not found in default path"
+            )
+        path = path.replace("{SamplePath}", sample_path)
+    elif sample_template_exists:
+        raise ValueError(f"no path setting found for sample {sample['Name']}")
+
+    # check for presence of colon to distinguish path to file and location within file
+    if ":" not in path:
+        log.warning(f"no colon found in path {path}, may not be able to find histogram")
+
+    return path
+
+
+def _collector(
+    histogram_folder: pathlib.Path, general_path: str, variation_path: str, method: str
+) -> route.ProcessorFunc:
+    """Returns the histogram-collecting function to be applied to template histograms.
+
+    Needed by ``cabinetry.route.apply_to_all_templates``. Could alternatively create a
+    ``Collector`` class that contains processors (see ``builder._Builder`` for an
+    example).
+
+    Args:
+        histogram_folder (pathlib.Path): folder to save the histograms to
+        general_path (str): template for paths to input files for histogram building
+        variation_path (str): default for VariationPath setting
+        method (str): backend to use for histogram production
+
+    Raises:
+            NotImplementedError: when requesting an unknown backend
+
+    Returns:
+        route.ProcessorFunc: function to apply to a template histogram
+    """
+
+    def collect_template(
+        region: Dict[str, Any],
+        sample: Dict[str, Any],
+        systematic: Dict[str, Any],
+        template: Optional[Literal["Up", "Down"]],
+    ) -> None:
+        """Collects a histogram and writes it to a file.
+
+        Args:
+            region (Dict[str, Any]): containing all region information
+            sample (Dict[str, Any]): containing all sample information
+            systematic (Dict[str, Any]): containing all systematic information
+            template (Optional[Literal["Up", "Down"]]): template considered: "Up",
+                "Down", or None for nominal
+        """
+        histo_path = _histo_path(
+            general_path, variation_path, region, sample, systematic, template
+        )
+
+        # obtain the histogram
+        if method == "uproot":
+            from cabinetry.contrib import histogram_reader
+
+            histogram = histogram_reader.with_uproot(histo_path)
+
+        else:
+            raise NotImplementedError(f"unknown backend {method}")
+
+        # store information in a Histogram instance and save it
+        utils._name_and_save(
+            histogram_folder,
+            histo.Histogram(histogram),
+            region,
+            sample,
+            systematic,
+            template,
+        )
+
+    return collect_template
diff --git a/src/cabinetry/template_postprocessor.py b/src/cabinetry/templates/postprocessor.py
similarity index 94%
rename from src/cabinetry/template_postprocessor.py
rename to src/cabinetry/templates/postprocessor.py
index 8312c3e9..30565426 100644
--- a/src/cabinetry/template_postprocessor.py
+++ b/src/cabinetry/templates/postprocessor.py
@@ -1,3 +1,5 @@
+"""Applies optional post-processing to template histograms."""
+
 import copy
 import logging
 import pathlib
@@ -129,7 +131,7 @@ def _postprocessor(histogram_folder: pathlib.Path) -> route.ProcessorFunc:
     ``Postprocessor`` class that contains processors.
 
     Args:
-        histogram_folder (Union[str, pathlib.Path]): folder containing histograms
+        histogram_folder (pathlib.Path): folder containing histograms
 
     Returns:
         route.ProcessorFunc: function to apply to a template histogram
@@ -182,14 +184,3 @@ def process_template(
         new_histogram.save(new_histo_path)
 
     return process_template
-
-
-def run(config: Dict[str, Any]) -> None:
-    """Applies postprocessing to all histograms.
-
-    Args:
-        config (Dict[str, Any]): cabinetry configuration
-    """
-    histogram_folder = pathlib.Path(config["General"]["HistogramFolder"])
-    postprocessor = _postprocessor(histogram_folder)
-    route.apply_to_all_templates(config, postprocessor)
diff --git a/src/cabinetry/templates/utils.py b/src/cabinetry/templates/utils.py
new file mode 100644
index 00000000..5d17ddf1
--- /dev/null
+++ b/src/cabinetry/templates/utils.py
@@ -0,0 +1,58 @@
+"""Contains utilities for template histogram handling."""
+
+import pathlib
+from typing import Any, Dict, List, Optional, Union
+
+from cabinetry import histo
+from cabinetry._typing import Literal
+
+
+def _check_for_override(
+    systematic: Dict[str, Any], template: Literal["Up", "Down"], option: str
+) -> Optional[Union[str, List[str]]]:
+    """Returns an override if specified by a template of a systematic.
+
+    Given a systematic and a string specifying which template is currently under
+    consideration, check whether the systematic defines an override for an option.
+    Return the override if it exists, otherwise return None.
+
+    Args:
+        systematic (Dict[str, Any]): containing all systematic information
+        template (Literal["Up", "Down"]): template considered: "Up" or "Down"
+        option (str): the option for which the presence of an override is checked
+
+    Returns:
+        Optional[Union[str, List[str]]]: either None if no override exists, or the
+        override
+    """
+    return systematic.get(template, {}).get(option, None)
+
+
+def _name_and_save(
+    histogram_folder: pathlib.Path,
+    histogram: histo.Histogram,
+    region: Dict[str, Any],
+    sample: Dict[str, Any],
+    systematic: Dict[str, Any],
+    template: Optional[Literal["Up", "Down"]],
+) -> None:
+    """Generates a unique name for a histogram and saves the histogram.
+
+    Args:
+        histogram_folder (pathlib.Path): folder to save the histograms to
+        histogram (histo.Histogram): histogram to save
+        region (Dict[str, Any]): containing all region information
+        sample (Dict[str, Any]): containing all sample information
+        systematic (Dict[str, Any]): containing all systematic
+        template (Optional[Literal["Up", "Down"]]): template considered: "Up",
+            "Down", or None for nominal
+    """
+    # generate a name for the histogram
+    histogram_name = histo.name(region, sample, systematic, template)
+
+    # check the histogram for common issues
+    histogram.validate(histogram_name)
+
+    # save it
+    histo_path = histogram_folder / histogram_name
+    histogram.save(histo_path)
diff --git a/src/cabinetry/visualize/__init__.py b/src/cabinetry/visualize/__init__.py
index 68cefd53..ceb6b7f2 100644
--- a/src/cabinetry/visualize/__init__.py
+++ b/src/cabinetry/visualize/__init__.py
@@ -12,7 +12,7 @@
 from cabinetry import fit
 from cabinetry import histo
 from cabinetry import model_utils
-from cabinetry import template_builder
+from cabinetry.templates import builder
 from cabinetry.visualize import plot_model
 from cabinetry.visualize import plot_result
 
@@ -188,11 +188,16 @@ def data_mc(
 
         if config is not None:
             # get the region dictionary from the config for binning / variable name
+            # for histogram inputs this information is not available, so need to fall
+            # back to defaults
             region_dict = configuration.region_dict(config, channel_name)
-            bin_edges = template_builder._binning(region_dict)
-            variable = region_dict["Variable"]
+            if region_dict.get("Binning", None) is not None:
+                bin_edges = builder._binning(region_dict)
+            else:
+                bin_edges = np.arange(len(data_yields[i_chan]) + 1)
+            variable = region_dict.get("Variable", "bin")
         else:
-            # fall back to defaults
+            # fall back to defaults if no config is specified
             bin_edges = np.arange(len(data_yields[i_chan]) + 1)
             variable = "bin"
 
@@ -263,7 +268,7 @@ def templates(
         List[Dict[str, Any]]: list of dictionaries, where each dictionary contains a
             figure and the associated region / sample / systematic names
     """
-    log.info("visualizing systematics templates")
+    log.info("visualizing systematic templates")
     histogram_folder = pathlib.Path(config["General"]["HistogramFolder"])
     figure_folder = pathlib.Path(figure_folder) / "templates"
 
@@ -305,7 +310,10 @@ def templates(
                     histogram_folder, region, sample, {}
                 )
                 bins = nominal_histo.bins
-                variable = region["Variable"]
+                # variable is a required config setting for ntuple inputs, but not for
+                # histogram inputs, so default to "observable" for these cases (the
+                # actual bin edges are preseved here, so not using "bin" as in data_mc)
+                variable = region.get("Variable", "observable")
                 nominal = {"yields": nominal_histo.yields, "stdev": nominal_histo.stdev}
 
                 # extract original and modified (after post-processing) variation
diff --git a/src/cabinetry/workspace.py b/src/cabinetry/workspace.py
index e15ff290..104c37bc 100644
--- a/src/cabinetry/workspace.py
+++ b/src/cabinetry/workspace.py
@@ -99,7 +99,7 @@ def normalization_modifier(systematic: Dict[str, Any]) -> Dict[str, Any]:
         Returns:
             Dict[str, Any]: single `normsys` modifier for ``pyhf`` workspace
         """
-        # take name of modifier from ModifierName if set, default to systematics name
+        # take name of modifier from ModifierName if set, default to systematic name
         modifier_name = systematic.get("ModifierName", systematic["Name"])
 
         modifier = {}
@@ -186,7 +186,7 @@ def normplusshape_modifiers(
             histo_yield_up = list(histogram_up.yields / norm_effect_up)
             histo_yield_down = list(histogram_down.yields / norm_effect_down)
 
-        # take name of modifier from ModifierName if set, default to systematics name
+        # take name of modifier from ModifierName if set, default to systematic name
         modifier_name = systematic.get("ModifierName", systematic["Name"])
 
         # add the normsys
diff --git a/utils/config_histograms.yml b/utils/config_histograms.yml
new file mode 100644
index 00000000..3eaa4dd2
--- /dev/null
+++ b/utils/config_histograms.yml
@@ -0,0 +1,51 @@
+General:
+  Measurement: "minimal_example"
+  POI: "Signal_norm"
+  HistogramFolder: "histograms/"
+  InputPath: "inputs/histograms.root:{RegionPath}/{SamplePath}/{VariationPath}"
+  VariationPath: "Nominal"
+
+Regions:
+  - Name: "Signal_region"
+    RegionPath: "SR"
+
+Samples:
+  - Name: "Data"
+    SamplePath: "Data"
+    Data: True
+
+  - Name: "Signal"
+    SamplePath: "Signal"
+
+  - Name: "Background"
+    SamplePath: "Background"
+
+Systematics:
+  - Name: "Luminosity"
+    Up:
+      Normalization: 0.05
+    Down:
+      Normalization: -0.05
+    Type: "Normalization"
+
+  - Name: "Modeling"
+    Up:
+      VariationPath: "Modeling_Up"
+    Down:
+      Symmetrize: True
+    Samples: "Background"
+    Type: "NormPlusShape"
+
+  - Name: "WeightBasedModeling"
+    Up:
+      VariationPath: "WeightBasedModeling_Up"
+    Down:
+      VariationPath: "WeightBasedModeling_Down"
+    Samples: "Background"
+    Type: "NormPlusShape"
+
+NormFactors:
+  - Name: "Signal_norm"
+    Samples: "Signal"
+    Nominal: 1
+    Bounds: [0, 10]
diff --git a/utils/create_histograms.py b/utils/create_histograms.py
new file mode 100644
index 00000000..21317d90
--- /dev/null
+++ b/utils/create_histograms.py
@@ -0,0 +1,49 @@
+"""Creates histograms used as input for a minimal example of cabinetry."""
+import os
+
+import boost_histogram as bh
+import numpy as np
+import uproot
+
+
+def run(output_directory):
+    bins = [200.0, 300.0, 400.0, 500.0, 600.0]
+    hist = bh.Histogram(bh.axis.Variable(bins), storage=bh.storage.Weight())
+    file_name = output_directory + "/histograms.root"
+    with uproot.recreate(file_name) as f:
+        yields = np.asarray([112.0, 112.0, 124.0, 66.0])
+        stdev = np.asarray([10.58300524, 10.58300524, 11.13552873, 8.1240384])
+        hist[...] = np.stack([yields, stdev ** 2], axis=-1)
+        f["SR/Data/Nominal"] = hist
+
+        yields = np.asarray([0.0, 1.58536913, 23.6164268, 24.54892223])
+        stdev = np.asarray([0.0, 0.19931166, 0.77410713, 0.78830503])
+        hist[...] = np.stack([yields, stdev ** 2], axis=-1)
+        f["SR/Signal/Nominal"] = hist
+
+        yields = np.asarray([112.73896936, 128.62169539, 88.10700838, 55.24607072])
+        stdev = np.asarray([4.76136678, 5.10645036, 4.21104367, 3.34933335])
+        hist[...] = np.stack([yields, stdev ** 2], axis=-1)
+        f["SR/Background/Nominal"] = hist
+
+        yields = np.asarray([53.85246451, 85.0382603, 90.75880537, 78.14459379])
+        stdev = np.asarray([3.29419354, 4.14450981, 4.27730978, 3.96701472])
+        hist[...] = np.stack([yields, stdev ** 2], axis=-1)
+        f["SR/Background/Modeling_Up"] = hist
+
+        yields = np.asarray([98.13569365, 154.1222757, 135.20449815, 103.14744392])
+        stdev = np.asarray([4.17025569, 6.14088444, 6.47920695, 6.2581315])
+        hist[...] = np.stack([yields, stdev ** 2], axis=-1)
+        f["SR/Background/WeightBasedModeling_Up"] = hist
+
+        yields = np.asarray([78.91727855, 90.03518677, 61.67490587, 38.6722495])
+        stdev = np.asarray([3.33295675, 3.57451525, 2.94773057, 2.34453334])
+        hist[...] = np.stack([yields, stdev ** 2], axis=-1)
+        f["SR/Background/WeightBasedModeling_Down"] = hist
+
+
+if __name__ == "__main__":
+    output_directory = "inputs/"
+    if not os.path.exists(output_directory):
+        os.mkdir(output_directory)
+    run(output_directory)
diff --git a/utils/create_ntuples.py b/utils/create_ntuples.py
index 9821128b..4ae6b63d 100644
--- a/utils/create_ntuples.py
+++ b/utils/create_ntuples.py
@@ -201,7 +201,7 @@ def run(output_directory, visualize=False):
 
 
 if __name__ == "__main__":
-    output_directory = "ntuples/"
+    output_directory = "inputs/"
     if not os.path.exists(output_directory):
         os.mkdir(output_directory)
     run(output_directory)


Test patch:
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index 908b69fa..8f932141 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -31,7 +31,7 @@ def test_cabinetry():
 
 
 # using autospec to catch changes in public API
-@mock.patch("cabinetry.template_builder.create_histograms", autospec=True)
+@mock.patch("cabinetry.templates.build", autospec=True)
 @mock.patch("cabinetry.configuration.validate", autospec=True)
 def test_templates(mock_validate, mock_create_histograms, cli_helpers, tmp_path):
     config = {"General": {"Measurement": "test_config"}}
@@ -53,7 +53,7 @@ def test_templates(mock_validate, mock_create_histograms, cli_helpers, tmp_path)
     assert mock_create_histograms.call_args == ((config,), {"method": "unknown"})
 
 
-@mock.patch("cabinetry.template_postprocessor.run", autospec=True)
+@mock.patch("cabinetry.templates.postprocess", autospec=True)
 @mock.patch("cabinetry.configuration.validate", autospec=True)
 def test_postprocess(mock_validate, mock_postprocess, cli_helpers, tmp_path):
     config = {"General": {"Measurement": "test_config"}}
@@ -104,7 +104,7 @@ def test_workspace(mock_validate, mock_build, cli_helpers, tmp_path):
     return_value=("model", "data"),
     autospec=True,
 )
-def test_fit(mock_util, mock_fit, mock_pulls, mock_corrmat, tmp_path):
+def test_fit(mock_utils, mock_fit, mock_pulls, mock_corrmat, tmp_path):
     workspace = {"workspace": "mock"}
     bestfit = np.asarray([1.0])
     uncertainty = np.asarray([0.1])
@@ -123,7 +123,7 @@ def test_fit(mock_util, mock_fit, mock_pulls, mock_corrmat, tmp_path):
     # default
     result = runner.invoke(cli.fit, [workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args_list == [((workspace,), {"asimov": False})]
+    assert mock_utils.call_args_list == [((workspace,), {"asimov": False})]
     assert mock_fit.call_args_list == [
         (("model", "data"), {"minos": None, "goodness_of_fit": False})
     ]
@@ -131,7 +131,7 @@ def test_fit(mock_util, mock_fit, mock_pulls, mock_corrmat, tmp_path):
     # Asimov
     result = runner.invoke(cli.fit, ["--asimov", workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": True})
+    assert mock_utils.call_args == ((workspace,), {"asimov": True})
     assert mock_fit.call_args == (
         ("model", "data"),
         {"minos": None, "goodness_of_fit": False},
@@ -140,7 +140,7 @@ def test_fit(mock_util, mock_fit, mock_pulls, mock_corrmat, tmp_path):
     # MINOS for one parameter
     result = runner.invoke(cli.fit, ["--minos", "par", workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": False})
+    assert mock_utils.call_args == ((workspace,), {"asimov": False})
     assert mock_fit.call_args == (
         ("model", "data"),
         {"minos": ["par"], "goodness_of_fit": False},
@@ -151,7 +151,7 @@ def test_fit(mock_util, mock_fit, mock_pulls, mock_corrmat, tmp_path):
         cli.fit, ["--minos", "par_a", "--minos", "par_b", workspace_path]
     )
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": False})
+    assert mock_utils.call_args == ((workspace,), {"asimov": False})
     assert mock_fit.call_args == (
         ("model", "data"),
         {"minos": ["par_a", "par_b"], "goodness_of_fit": False},
@@ -160,7 +160,7 @@ def test_fit(mock_util, mock_fit, mock_pulls, mock_corrmat, tmp_path):
     # goodness-of-fit
     result = runner.invoke(cli.fit, ["--goodness_of_fit", workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": False})
+    assert mock_utils.call_args == ((workspace,), {"asimov": False})
     assert mock_fit.call_args == (
         ("model", "data"),
         {"minos": None, "goodness_of_fit": True},
@@ -213,7 +213,7 @@ def test_fit(mock_util, mock_fit, mock_pulls, mock_corrmat, tmp_path):
     return_value=("model", "data"),
     autospec=True,
 )
-def test_ranking(mock_util, mock_fit, mock_rank, mock_vis, tmp_path):
+def test_ranking(mock_utils, mock_fit, mock_rank, mock_vis, tmp_path):
     workspace = {"workspace": "mock"}
     bestfit = np.asarray([1.0])
     uncertainty = np.asarray([0.1])
@@ -232,7 +232,7 @@ def test_ranking(mock_util, mock_fit, mock_rank, mock_vis, tmp_path):
     # default
     result = runner.invoke(cli.ranking, [workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args_list == [((workspace,), {"asimov": False})]
+    assert mock_utils.call_args_list == [((workspace,), {"asimov": False})]
     assert mock_fit.call_args_list == [(("model", "data"), {})]
     assert mock_rank.call_args_list == [
         (("model", "data"), {"fit_results": fit_results})
@@ -250,7 +250,7 @@ def test_ranking(mock_util, mock_fit, mock_rank, mock_vis, tmp_path):
         ["--asimov", "--max_pars", 3, "--figfolder", "folder", workspace_path],
     )
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": True})
+    assert mock_utils.call_args == ((workspace,), {"asimov": True})
     assert mock_fit.call_args == (("model", "data"), {})
     assert mock_rank.call_args == (("model", "data"), {"fit_results": fit_results})
     assert mock_vis.call_args[1] == {"figure_folder": "folder", "max_pars": 3}
@@ -267,7 +267,7 @@ def test_ranking(mock_util, mock_fit, mock_rank, mock_vis, tmp_path):
     return_value=("model", "data"),
     autospec=True,
 )
-def test_scan(mock_util, mock_scan, mock_vis, tmp_path):
+def test_scan(mock_utils, mock_scan, mock_vis, tmp_path):
     workspace = {"workspace": "mock"}
     workspace_path = str(tmp_path / "workspace.json")
 
@@ -285,7 +285,7 @@ def test_scan(mock_util, mock_scan, mock_vis, tmp_path):
     # default
     result = runner.invoke(cli.scan, [workspace_path, par_name])
     assert result.exit_code == 0
-    assert mock_util.call_args_list == [((workspace,), {"asimov": False})]
+    assert mock_utils.call_args_list == [((workspace,), {"asimov": False})]
     assert mock_scan.call_args_list == [
         (("model", "data", par_name), {"par_range": None, "n_steps": 11})
     ]
@@ -337,7 +337,7 @@ def test_scan(mock_util, mock_scan, mock_vis, tmp_path):
         ],
     )
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": True})
+    assert mock_utils.call_args == ((workspace,), {"asimov": True})
     assert mock_scan.call_args == (
         ("model", "data", par_name),
         {"par_range": (0.0, 2.0), "n_steps": 21},
@@ -362,7 +362,7 @@ def test_scan(mock_util, mock_scan, mock_vis, tmp_path):
     return_value=("model", "data"),
     autospec=True,
 )
-def test_limit(mock_util, mock_limit, mock_vis, tmp_path):
+def test_limit(mock_utils, mock_limit, mock_vis, tmp_path):
     workspace = {"workspace": "mock"}
     workspace_path = str(tmp_path / "workspace.json")
 
@@ -383,7 +383,7 @@ def test_limit(mock_util, mock_limit, mock_vis, tmp_path):
     # default
     result = runner.invoke(cli.limit, [workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args_list == [((workspace,), {"asimov": False})]
+    assert mock_utils.call_args_list == [((workspace,), {"asimov": False})]
     assert mock_limit.call_args_list == [(("model", "data"), {"tolerance": 0.01})]
     assert mock_vis.call_count == 1
     assert np.allclose(
@@ -407,7 +407,7 @@ def test_limit(mock_util, mock_limit, mock_vis, tmp_path):
         ["--asimov", "--tolerance", "0.1", "--figfolder", "folder", workspace_path],
     )
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": True})
+    assert mock_utils.call_args == ((workspace,), {"asimov": True})
     assert mock_limit.call_args == (("model", "data"), {"tolerance": 0.1})
     assert mock_vis.call_args[1] == {"figure_folder": "folder"}
 
@@ -418,7 +418,7 @@ def test_limit(mock_util, mock_limit, mock_vis, tmp_path):
     return_value=("model", "data"),
     autospec=True,
 )
-def test_significance(mock_util, mock_sig, tmp_path):
+def test_significance(mock_utils, mock_sig, tmp_path):
     workspace = {"workspace": "mock"}
     workspace_path = str(tmp_path / "workspace.json")
 
@@ -431,13 +431,13 @@ def test_significance(mock_util, mock_sig, tmp_path):
     # default
     result = runner.invoke(cli.significance, [workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args_list == [((workspace,), {"asimov": False})]
+    assert mock_utils.call_args_list == [((workspace,), {"asimov": False})]
     assert mock_sig.call_args_list == [(("model", "data"), {})]
 
     # Asimov
     result = runner.invoke(cli.significance, ["--asimov", workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {"asimov": True})
+    assert mock_utils.call_args == ((workspace,), {"asimov": True})
     assert mock_sig.call_args == (("model", "data"), {})
 
 
@@ -459,7 +459,7 @@ def test_significance(mock_util, mock_sig, tmp_path):
     autospec=True,
 )
 def test_data_mc(
-    mock_util, mock_validate, mock_fit, mock_pred, mock_vis, cli_helpers, tmp_path
+    mock_utils, mock_validate, mock_fit, mock_pred, mock_vis, cli_helpers, tmp_path
 ):
     workspace = {"workspace": "mock"}
     workspace_path = str(tmp_path / "workspace.json")
@@ -473,7 +473,7 @@ def test_data_mc(
     # default
     result = runner.invoke(cli.data_mc, [workspace_path])
     assert result.exit_code == 0
-    assert mock_util.call_args_list == [((workspace,), {})]
+    assert mock_utils.call_args_list == [((workspace,), {})]
     assert mock_validate.call_count == 0
     assert mock_fit.call_count == 0
     assert mock_pred.call_args_list == [(("model",), {"fit_results": None})]
@@ -497,7 +497,7 @@ def test_data_mc(
         [workspace_path, "--config", config_path, "--postfit", "--figfolder", "folder"],
     )
     assert result.exit_code == 0
-    assert mock_util.call_args == ((workspace,), {})
+    assert mock_utils.call_args == ((workspace,), {})
     assert mock_validate.call_args_list == [((config,), {})]
     assert mock_fit.call_args_list == [(("model", "data"), {})]
     assert mock_pred.call_args == (("model",), {"fit_results": fit_results})
diff --git a/tests/conftest.py b/tests/conftest.py
index 894b6db1..92518ae6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,5 @@
+import boost_histogram as bh
+import numpy as np
 import pytest
 import uproot
 
@@ -8,6 +10,13 @@ def create_ntuple(fname, treename, varname, var_array, weightname, weight_array)
         with uproot.recreate(fname) as f:
             f[treename] = {varname: var_array, weightname: weight_array}
 
+    @staticmethod
+    def create_histogram(fname, histname, bins, yields, stdev):
+        hist = bh.Histogram(bh.axis.Variable(bins), storage=bh.storage.Weight())
+        hist[...] = np.stack([yields, stdev ** 2], axis=-1)
+        with uproot.recreate(fname) as f:
+            f[histname] = hist
+
 
 @pytest.fixture
 def utils():
diff --git a/tests/contrib/test_histogram_creation.py b/tests/contrib/test_contrib_histogram_creator.py
similarity index 81%
rename from tests/contrib/test_histogram_creation.py
rename to tests/contrib/test_contrib_histogram_creator.py
index 875be191..9b0cd45a 100644
--- a/tests/contrib/test_histogram_creation.py
+++ b/tests/contrib/test_contrib_histogram_creator.py
@@ -1,9 +1,9 @@
 import numpy as np
 
-from cabinetry.contrib import histogram_creation
+from cabinetry.contrib import histogram_creator
 
 
-def test_from_uproot(tmp_path, utils):
+def test_with_uproot(tmp_path, utils):
     fnames = [tmp_path / "test.root"]
     treename = "tree"
     varname = "var"
@@ -17,13 +17,13 @@ def test_from_uproot(tmp_path, utils):
     )
 
     # read - no weights or selection
-    hist = histogram_creation.from_uproot(fnames, treename, varname, bins)
+    hist = histogram_creator.with_uproot(fnames, treename, varname, bins)
     assert np.allclose(hist.values(), [1, 1, 2])
     assert np.allclose(np.sqrt(hist.variances()), [1, 1, 1.41421356])
 
     # read - with selection cut
     selection = "var < 3.1"
-    hist = histogram_creation.from_uproot(
+    hist = histogram_creator.with_uproot(
         fnames, treename, varname, bins, selection_filter=selection
     )
     assert np.allclose(hist.values(), [1, 1, 1])
@@ -31,7 +31,7 @@ def test_from_uproot(tmp_path, utils):
 
     # read - with weight
     weightname_apply = "weight*2"
-    hist = histogram_creation.from_uproot(
+    hist = histogram_creator.with_uproot(
         fnames, treename, varname, bins, weight=weightname_apply
     )
     assert np.allclose(hist.values(), [2, 2, 6])
@@ -39,7 +39,7 @@ def test_from_uproot(tmp_path, utils):
 
     # read - with weight that is only a float
     weight_float = "2.0"
-    hist = histogram_creation.from_uproot(
+    hist = histogram_creator.with_uproot(
         fnames, treename, varname, bins, weight=weight_float
     )
     assert np.allclose(hist.values(), [2, 2, 4])
@@ -53,12 +53,12 @@ def test_from_uproot(tmp_path, utils):
 
     # read - with wildcard, matching two files
     fnames = [tmp_path / "test*.root"]
-    hist = histogram_creation.from_uproot(fnames, treename, varname, bins)
+    hist = histogram_creator.with_uproot(fnames, treename, varname, bins)
     assert np.allclose(hist.values(), [2, 2, 4])
     assert np.allclose(np.sqrt(hist.variances()), [1.41421356, 1.41421356, 2])
 
     # read - with two list entries
     fnames = [tmp_path / "test.root", tmp_path / "test_2.root"]
-    hist = histogram_creation.from_uproot(fnames, treename, varname, bins)
+    hist = histogram_creator.with_uproot(fnames, treename, varname, bins)
     assert np.allclose(hist.values(), [2, 2, 4])
     assert np.allclose(np.sqrt(hist.variances()), [1.41421356, 1.41421356, 2])
diff --git a/tests/contrib/test_contrib_histogram_reader.py b/tests/contrib/test_contrib_histogram_reader.py
new file mode 100644
index 00000000..55842b5d
--- /dev/null
+++ b/tests/contrib/test_contrib_histogram_reader.py
@@ -0,0 +1,18 @@
+import numpy as np
+
+from cabinetry.contrib import histogram_reader
+
+
+def test_with_uproot(tmp_path, utils):
+    fname = tmp_path / "test.root"
+    histname = "h1"
+    bins = np.asarray([1, 2, 3])
+    yields = np.asarray([1.0, 2.0])
+    stdev = np.asarray([0.1, 0.2])
+    # create something to read
+    utils.create_histogram(fname, histname, bins, yields, stdev)
+
+    hist = histogram_reader.with_uproot(f"{fname}:{histname}")
+    assert np.allclose(hist.axes[0].edges, bins)
+    assert np.allclose(hist.values(), yields)
+    assert np.allclose(np.sqrt(hist.variances()), stdev)
diff --git a/tests/fit/test_results_containers.py b/tests/fit/test_fit_results_containers.py
similarity index 100%
rename from tests/fit/test_results_containers.py
rename to tests/fit/test_fit_results_containers.py
diff --git a/tests/templates/test_templates.py b/tests/templates/test_templates.py
new file mode 100644
index 00000000..c106a7a2
--- /dev/null
+++ b/tests/templates/test_templates.py
@@ -0,0 +1,87 @@
+import logging
+import pathlib
+from unittest import mock
+
+from cabinetry import templates
+
+
+@mock.patch("cabinetry.route.apply_to_all_templates")
+@mock.patch("cabinetry.templates.builder._Builder")
+def test_build(mock_builder, mock_apply):
+    config = {"General": {"HistogramFolder": "path/", "InputPath": "file.root"}}
+    method = "uproot"
+
+    # no router
+    templates.build(config, method)
+    assert mock_builder.call_args_list == [
+        ((pathlib.Path("path/"), "file.root", method), {})
+    ]
+    assert mock_apply.call_count == 1
+    config_call, func_call = mock_apply.call_args[0]
+    assert config_call == config
+    assert func_call._extract_mock_name() == "_Builder()._create_histogram"
+    assert mock_apply.call_args[1] == {"match_func": None}
+
+    # including a router
+    mock_router = mock.MagicMock()
+    templates.build(config, method, router=mock_router)
+
+    # verify wrapper was set
+    assert (
+        mock_router.template_builder_wrapper._extract_mock_name()
+        == "_Builder()._wrap_custom_template_builder"
+    )
+
+    assert mock_apply.call_count == 2  # 1 from before
+    config_call, func_call = mock_apply.call_args[0]
+    assert config_call == config
+    assert func_call._extract_mock_name() == "_Builder()._create_histogram"
+    assert mock_apply.call_args[1] == {
+        "match_func": mock_router._find_template_builder_match
+    }
+
+
+@mock.patch("cabinetry.route.apply_to_all_templates")
+@mock.patch("cabinetry.templates.collector._collector", return_value="func")
+def test_collect(mock_collector, mock_apply, caplog):
+    caplog.set_level(logging.DEBUG)
+    config = {
+        "General": {
+            "HistogramFolder": "path/",
+            "InputPath": "f.root:{VariationPath}",
+            "VariationPath": "nominal",
+        }
+    }
+    method = "uproot"
+
+    templates.collect(config, method)
+    assert mock_collector.call_args_list == [
+        ((pathlib.Path("path/"), "f.root:{VariationPath}", "nominal", method), {})
+    ]
+    assert mock_apply.call_args_list == [((config, "func"), {})]
+
+    caplog.clear()
+
+    # no VariationPath in general settings
+    config = {
+        "General": {"HistogramFolder": "path/", "InputPath": "f.root:{VariationPath}"}
+    }
+    templates.collect(config, method)
+    assert 'no VariationPath specified in general settings, defaulting to ""' in [
+        rec.message for rec in caplog.records
+    ]
+    assert mock_collector.call_args == (
+        (pathlib.Path("path/"), "f.root:{VariationPath}", "", method),
+        {},
+    )
+    caplog.set_level(logging.DEBUG)
+
+
+@mock.patch("cabinetry.route.apply_to_all_templates")
+@mock.patch("cabinetry.templates.postprocessor._postprocessor", return_value="func")
+def test_run(mock_postprocessor, mock_apply):
+    config = {"General": {"HistogramFolder": "path/"}}
+
+    templates.postprocess(config)
+    assert mock_postprocessor.call_args_list == [((pathlib.Path("path/"),), {})]
+    assert mock_apply.call_args_list == [((config, "func"), {})]
diff --git a/tests/templates/test_templates_builder.py b/tests/templates/test_templates_builder.py
new file mode 100644
index 00000000..5421d73a
--- /dev/null
+++ b/tests/templates/test_templates_builder.py
@@ -0,0 +1,310 @@
+import logging
+import pathlib
+from unittest import mock
+
+import boost_histogram as bh
+import numpy as np
+import pytest
+
+from cabinetry.templates import builder
+
+
+def test__ntuple_paths(caplog):
+    caplog.set_level(logging.DEBUG)
+
+    # only general path, no override
+    assert builder._ntuple_paths("path.root", {}, {}, {}, None) == [
+        pathlib.Path("path.root")
+    ]
+
+    # general path with region and sample templates
+    assert (
+        builder._ntuple_paths(
+            "{RegionPath}/{SamplePath}",
+            {"RegionPath": "region"},
+            {"SamplePath": "sample.root"},
+            {},
+            None,
+        )
+        == [pathlib.Path("region/sample.root")]
+    )
+
+    # SamplePath with list of two samples
+    assert (
+        builder._ntuple_paths(
+            "{RegionPath}/{SamplePath}",
+            {"RegionPath": "region"},
+            {"SamplePath": ["sample.root", "new.root"]},
+            {},
+            None,
+        )
+        == [pathlib.Path("region/sample.root"), pathlib.Path("region/new.root")]
+    )
+
+    # systematic with override for RegionPath and SamplePath
+    assert (
+        builder._ntuple_paths(
+            "{RegionPath}/{SamplePath}",
+            {"RegionPath": "reg_1"},
+            {"SamplePath": "path.root"},
+            {
+                "Name": "variation",
+                "Up": {
+                    "SamplePath": ["variation.root", "new.root"],
+                    "RegionPath": "reg_2",
+                },
+            },
+            "Up",
+        )
+        == [pathlib.Path("reg_2/variation.root"), pathlib.Path("reg_2/new.root")]
+    )
+
+    # systematic without override
+    assert builder._ntuple_paths(
+        "{SamplePath}", {}, {"SamplePath": "path.root"}, {"Name": "variation"}, "Up"
+    ) == [pathlib.Path("path.root")]
+
+    caplog.clear()
+
+    # warning: no region path in template
+    assert builder._ntuple_paths(
+        "path.root", {"RegionPath": "region.root"}, {}, {}, None
+    ) == [pathlib.Path("path.root")]
+    assert "region override specified, but {RegionPath} not found in default path" in [
+        rec.message for rec in caplog.records
+    ]
+    caplog.clear()
+
+    # warning: no sample path in template
+    assert builder._ntuple_paths(
+        "path.root", {}, {"SamplePath": "sample.root"}, {}, None
+    ) == [pathlib.Path("path.root")]
+    assert "sample override specified, but {SamplePath} not found in default path" in [
+        rec.message for rec in caplog.records
+    ]
+    caplog.clear()
+
+    # error: no override for {RegionPath}
+    with pytest.raises(ValueError, match="no path setting found for region region"):
+        builder._ntuple_paths("{RegionPath}", {"Name": "region"}, {}, {}, None)
+
+    # error: no override for {SamplePath}
+    with pytest.raises(ValueError, match="no path setting found for sample sample"):
+        builder._ntuple_paths("{SamplePath}", {}, {"Name": "sample"}, {}, None)
+
+
+def test__variable():
+    # no override
+    assert builder._variable({"Variable": "jet_pt"}, {}, {}, None) == "jet_pt"
+
+    # systematic with override
+    assert (
+        builder._variable(
+            {"Variable": "jet_pt"},
+            {},
+            {"Name": "variation", "Up": {"Variable": "jet_pt_up"}},
+            "Up",
+        )
+        == "jet_pt_up"
+    )
+
+    # systematic without override
+    assert (
+        builder._variable({"Variable": "jet_pt"}, {}, {"Name": "variation"}, "Up")
+        == "jet_pt"
+    )
+
+
+def test__filter():
+    # no override
+    assert builder._filter({"Filter": "jet_pt > 0"}, {}, {}, None) == "jet_pt > 0"
+
+    # no filter
+    assert builder._filter({}, {}, {}, None) is None
+
+    # systematic with override
+    assert (
+        builder._filter(
+            {"Filter": "jet_pt > 0"},
+            {},
+            {"Name": "variation", "Up": {"Filter": "jet_pt > 100"}},
+            "Up",
+        )
+        == "jet_pt > 100"
+    )
+
+    # systematic without override
+    assert (
+        builder._filter({"Filter": "jet_pt > 0"}, {}, {"Name": "variation"}, "Up")
+        == "jet_pt > 0"
+    )
+
+    # sample-specific override
+    assert (
+        builder._filter({"Filter": "jet_pt > 0"}, {"Filter": "jet_pt > 100"}, {}, None)
+        == "jet_pt > 100"
+    )
+
+    # sample-specific override, again overridden by systematic
+    assert (
+        builder._filter(
+            {"Filter": "jet_pt > 0"},
+            {"Filter": "jet_pt > 100"},
+            {"Name": "variation", "Up": {"Filter": "jet_pt > 200"}},
+            "Up",
+        )
+        == "jet_pt > 200"
+    )
+
+
+def test__weight():
+    # no override
+    assert builder._weight({}, {"Weight": "weight_mc"}, {}, None) == "weight_mc"
+
+    # no weight
+    assert builder._weight({}, {}, {}, None) is None
+
+    # systematic with override
+    assert (
+        builder._weight(
+            {},
+            {"Weight": "weight_mc"},
+            {"Name": "variation", "Up": {"Weight": "weight_modified"}},
+            "Up",
+        )
+        == "weight_modified"
+    )
+
+    # systematic without override
+    assert (
+        builder._weight({}, {"Weight": "weight_mc"}, {"Name": "variation"}, "Up")
+        == "weight_mc"
+    )
+
+
+def test__position_in_file():
+    # no override
+    assert builder._position_in_file({"Tree": "tree_name"}, {}, None) == "tree_name"
+
+    # systematic with override
+    assert (
+        builder._position_in_file(
+            {"Tree": "nominal"}, {"Name": "variation", "Up": {"Tree": "up_tree"}}, "Up"
+        )
+        == "up_tree"
+    )
+
+    # systematic without override
+    assert (
+        builder._position_in_file({"Tree": "nominal"}, {"Name": "variation"}, "Up")
+        == "nominal"
+    )
+
+
+def test__binning():
+    np.testing.assert_equal(builder._binning({"Binning": [1, 2, 3]}), [1, 2, 3])
+    with pytest.raises(NotImplementedError, match="cannot determine binning"):
+        builder._binning({})
+
+
+def test__Builder():
+    builder_instance = builder._Builder(pathlib.Path("path"), "file.root", "uproot")
+    assert builder_instance.histogram_folder == pathlib.Path("path")
+    assert builder_instance.general_path == "file.root"
+    assert builder_instance.method == "uproot"
+
+
+@mock.patch("cabinetry.templates.utils._name_and_save")
+@mock.patch("cabinetry.histo.Histogram", return_value="cabinetry_histogram")
+@mock.patch("cabinetry.contrib.histogram_creator.with_uproot", return_value="histogram")
+@mock.patch(
+    "cabinetry.templates.builder._ntuple_paths",
+    return_value=[pathlib.Path("path_to_ntuple")],
+)
+def test__Builder_create_histogram(
+    mock_path, mock_uproot_builder, mock_histo, mock_save
+):
+    histogram_folder = pathlib.Path("path")
+    general_path = "{SamplePath}"
+    # the binning [0] is not a proper binning, but simplifies the comparison
+    region = {"Name": "test_region", "Variable": "x", "Binning": [0], "Filter": "x>3"}
+    sample = {
+        "Name": "sample",
+        "Tree": "tree",
+        "SamplePath": "path_to_sample",
+        "Weight": "weight_mc",
+    }
+    systematic = {}
+    template = "Up"
+
+    builder_instance = builder._Builder(histogram_folder, general_path, "uproot")
+    builder_instance._create_histogram(region, sample, systematic, template)
+
+    # call to path creation
+    assert mock_path.call_args_list == [
+        ((general_path, region, sample, systematic, template), {})
+    ]
+
+    # verify the backend call happened properly
+    assert mock_uproot_builder.call_args_list == [
+        (
+            ([pathlib.Path("path_to_ntuple")], "tree", "x", [0]),
+            {"weight": "weight_mc", "selection_filter": "x>3"},
+        )
+    ]
+
+    # conversion from bh.Histogram to cabinetry Histogram
+    assert mock_histo.call_args_list == [(("histogram",), {})]
+
+    # verify the call for saving wrapped histogram
+    assert mock_save.call_args_list == [
+        (
+            (
+                histogram_folder,
+                "cabinetry_histogram",
+                region,
+                sample,
+                systematic,
+                template,
+            ),
+            {},
+        )
+    ]
+
+    # other backends
+    builder_unknown = builder._Builder(histogram_folder, "{SamplePath}", "unknown")
+    with pytest.raises(NotImplementedError, match="unknown backend unknown"):
+        builder_unknown._create_histogram(region, sample, systematic, template)
+
+
+@mock.patch("cabinetry.templates.utils._name_and_save")
+def test__Builder__wrap_custom_template_builder(mock_save):
+    histogram = bh.Histogram(bh.axis.Variable([0, 1]))
+    region = {"Name": "test_region"}
+    sample = {"Name": "sample"}
+    systematic = {}
+    histogram_folder = pathlib.Path("path")
+
+    def test_func(reg, sam, sys, tem):
+        return histogram
+
+    builder_instance = builder._Builder(histogram_folder, "file.root", "uproot")
+    wrapped_func = builder_instance._wrap_custom_template_builder(test_func)
+
+    # check the behavior of the wrapped function
+    # when called, it should save the returned histogram
+    wrapped_func(region, sample, systematic, "Up")
+
+    assert mock_save.call_args_list == [
+        ((histogram_folder, histogram, region, sample, systematic, "Up"), {})
+    ]
+
+    # wrapped function returns wrong type
+    def test_func_wrong_return(reg, sam, sys, tem):
+        return None
+
+    wrapped_func_wrong_return = builder_instance._wrap_custom_template_builder(
+        test_func_wrong_return
+    )
+    with pytest.raises(TypeError, match="must return a boost_histogram.Histogram"):
+        wrapped_func_wrong_return(region, sample, systematic, "Up")
diff --git a/tests/templates/test_templates_collector.py b/tests/templates/test_templates_collector.py
new file mode 100644
index 00000000..432efcab
--- /dev/null
+++ b/tests/templates/test_templates_collector.py
@@ -0,0 +1,152 @@
+import logging
+import pathlib
+from unittest import mock
+
+import pytest
+
+from cabinetry.templates import collector
+
+
+def test__histo_path(caplog):
+    caplog.set_level(logging.DEBUG)
+
+    # only general path, no override
+    assert collector._histo_path("path.root:h1", "", {}, {}, {}, None) == "path.root:h1"
+
+    # general path with region, sample and nominal variation
+    assert (
+        collector._histo_path(
+            "{RegionPath}.root:{SamplePath}_{VariationPath}",
+            "nominal",
+            {"RegionPath": "region"},
+            {"SamplePath": "sample"},
+            {},
+            None,
+        )
+        == "region.root:sample_nominal"
+    )
+
+    # systematic with override for VariationPath
+    assert (
+        collector._histo_path(
+            "{RegionPath}.root:{SamplePath}_{VariationPath}",
+            "nominal",
+            {"RegionPath": "reg_1"},
+            {"SamplePath": "path"},
+            {"Name": "variation", "Up": {"VariationPath": "up"}},
+            "Up",
+        )
+        == "reg_1.root:path_up"
+    )
+
+    caplog.clear()
+
+    # systematic without override, results in warning from VariationPath
+    assert (
+        collector._histo_path(
+            "f.root:h1_{VariationPath}",
+            "",
+            {"Name": "reg"},
+            {"Name": "sam"},
+            {"Name": "variation"},
+            "Up",
+        )
+        == "f.root:h1_"
+    )
+    assert "no VariationPath override specified for reg / sam / variation Up" in [
+        rec.message for rec in caplog.records
+    ]
+    caplog.clear()
+
+    # warning: no region path in template
+    assert (
+        collector._histo_path(
+            "f.root:h1", "", {"RegionPath": "region.root"}, {}, {}, None
+        )
+        == "f.root:h1"
+    )
+    assert "region override specified, but {RegionPath} not found in default path" in [
+        rec.message for rec in caplog.records
+    ]
+    caplog.clear()
+
+    # warning: no sample path in template
+    assert (
+        collector._histo_path(
+            "f.root:h1", "", {}, {"SamplePath": "sample.root"}, {}, None
+        )
+        == "f.root:h1"
+    )
+    assert "sample override specified, but {SamplePath} not found in default path" in [
+        rec.message for rec in caplog.records
+    ]
+    caplog.clear()
+
+    # warning: no colon in path
+    assert collector._histo_path("f.root", "", {}, {}, {}, None) == "f.root"
+    assert "no colon found in path f.root, may not be able to find histogram" in [
+        rec.message for rec in caplog.records
+    ]
+    caplog.clear()
+
+    # error: no override for {RegionPath}
+    with pytest.raises(ValueError, match="no path setting found for region region"):
+        collector._histo_path("{RegionPath}", "", {"Name": "region"}, {}, {}, None)
+
+    # error: no override for {SamplePath}
+    with pytest.raises(ValueError, match="no path setting found for sample sample"):
+        collector._histo_path("{SamplePath}", "", {}, {"Name": "sample"}, {}, None)
+
+
+@mock.patch("cabinetry.templates.utils._name_and_save")
+@mock.patch("cabinetry.histo.Histogram", return_value="cabinetry_histogram")
+@mock.patch("cabinetry.contrib.histogram_reader.with_uproot", return_value="histogram")
+@mock.patch("cabinetry.templates.collector._histo_path", return_value="path_to_hist")
+def test__collector(mock_path, mock_uproot_hist, mock_histo, mock_save):
+    histogram_folder = pathlib.Path("path")
+    general_path = "f.root:{variation_path}"
+    variation_path = "nominal"
+    processor = collector._collector(
+        histogram_folder, general_path, variation_path, "uproot"
+    )
+
+    region = {"Name": "region"}
+    sample = {"Name": "sample"}
+    systematic = {"Name": "systematic"}
+    template = "Up"
+
+    # execute processor
+    processor(region, sample, systematic, template)
+
+    # call to path creation
+    assert mock_path.call_args_list == [
+        ((general_path, variation_path, region, sample, systematic, template), {})
+    ]
+
+    # call to uproot backend
+    assert mock_uproot_hist.call_args_list == [(("path_to_hist",), {})]
+
+    # conversion from bh.Histogram to cabinetry Histogram
+    assert mock_histo.call_args_list == [(("histogram",), {})]
+
+    # verify the call for saving wrapped histogram
+    assert mock_save.call_args_list == [
+        (
+            (
+                histogram_folder,
+                "cabinetry_histogram",
+                region,
+                sample,
+                systematic,
+                template,
+            ),
+            {},
+        )
+    ]
+
+    # other backends
+    processor_unknown = collector._collector(
+        histogram_folder, general_path, variation_path, "unknown"
+    )
+    with pytest.raises(NotImplementedError, match="unknown backend unknown"):
+        processor_unknown(region, sample, systematic, None)
diff --git a/tests/test_template_postprocessor.py b/tests/templates/test_templates_postprocessor.py
similarity index 77%
rename from tests/test_template_postprocessor.py
rename to tests/templates/test_templates_postprocessor.py
index 5d305450..c3971fd0 100644
--- a/tests/test_template_postprocessor.py
+++ b/tests/templates/test_templates_postprocessor.py
@@ -6,7 +6,7 @@
 import pytest
 
 from cabinetry import histo
-from cabinetry import template_postprocessor
+from cabinetry.templates import postprocessor
 
 
 @pytest.mark.parametrize(
@@ -21,7 +21,7 @@
 )
 def test__fix_stat_unc(test_histo, fixed_stdev):
     name = "test_histo"
-    template_postprocessor._fix_stat_unc(test_histo, name)
+    postprocessor._fix_stat_unc(test_histo, name)
     assert np.allclose(test_histo.stdev, fixed_stdev)
 
 
@@ -30,7 +30,7 @@ def test__apply_353qh_twice(mock_smooth):
     var = histo.Histogram.from_arrays([1, 2, 3], [1, 1.5], [0.1, 0.1])
     nom = histo.Histogram.from_arrays([1, 2, 3], [1, 1.2], [0.1, 0.1])
 
-    template_postprocessor._apply_353qh_twice(var, nom, "abc")
+    postprocessor._apply_353qh_twice(var, nom, "abc")
 
     assert np.allclose(nom.yields, [1, 1.2])  # nominal unchanged
     # result of smoothing is [1, 1.3], multiplied by [1, 1.2] -> [1, 1.56]
@@ -43,36 +43,36 @@ def test__smoothing_algorithm():
     sam = {"Name": "sam"}
     sys = {"Name": "sys"}
 
-    assert template_postprocessor._smoothing_algorithm(reg, sam, sys) is None
+    assert postprocessor._smoothing_algorithm(reg, sam, sys) is None
 
     sys = {"Name": "sys", "Smoothing": {"Algorithm": "abc"}}
-    assert template_postprocessor._smoothing_algorithm(reg, sam, sys) == "abc"
+    assert postprocessor._smoothing_algorithm(reg, sam, sys) == "abc"
 
     # region is missing in setting
     sys = {"Name": "sys", "Smoothing": {"Algorithm": "abc", "Regions": "r"}}
-    assert template_postprocessor._smoothing_algorithm(reg, sam, sys) is None
+    assert postprocessor._smoothing_algorithm(reg, sam, sys) is None
 
     # region is included in setting
     sys = {"Name": "sys", "Smoothing": {"Algorithm": "abc", "Regions": ["r", "reg"]}}
-    assert template_postprocessor._smoothing_algorithm(reg, sam, sys) == "abc"
+    assert postprocessor._smoothing_algorithm(reg, sam, sys) == "abc"
 
     # sample is missing in setting
     sys = {"Name": "sys", "Smoothing": {"Algorithm": "abc", "Samples": "s"}}
-    assert template_postprocessor._smoothing_algorithm(reg, sam, sys) is None
+    assert postprocessor._smoothing_algorithm(reg, sam, sys) is None
 
     # sample is included in setting
     sys = {"Name": "sys", "Smoothing": {"Algorithm": "abc", "Samples": ["s", "sam"]}}
-    assert template_postprocessor._smoothing_algorithm(reg, sam, sys) == "abc"
+    assert postprocessor._smoothing_algorithm(reg, sam, sys) == "abc"
 
 
-@mock.patch("cabinetry.template_postprocessor._apply_353qh_twice")
-@mock.patch("cabinetry.template_postprocessor._fix_stat_unc")
+@mock.patch("cabinetry.templates.postprocessor._apply_353qh_twice")
+@mock.patch("cabinetry.templates.postprocessor._fix_stat_unc")
 def test_apply_postprocessing(mock_stat, mock_smooth, caplog):
     caplog.set_level(logging.DEBUG)
     histogram = histo.Histogram.from_arrays([1, 2, 3], [1, 1], [float("nan"), 0.2])
     nom_hist = histo.Histogram.from_arrays([1, 2, 3], [2, 2], [0.1, 0.2])
     name = "test_histo"
-    modified_histogram = template_postprocessor.apply_postprocessing(
+    modified_histogram = postprocessor.apply_postprocessing(
         histogram, name, smoothing_algorithm="353QH, twice", nominal_histogram=nom_hist
     )
 
@@ -102,7 +102,7 @@ def test_apply_postprocessing(mock_stat, mock_smooth, caplog):
     assert np.allclose(histogram.stdev, modified_histogram.stdev, equal_nan=True)
 
     # unknown smoothing algorithm
-    _ = template_postprocessor.apply_postprocessing(
+    _ = postprocessor.apply_postprocessing(
         histogram, name, smoothing_algorithm="abc", nominal_histogram=nom_hist
     )
     assert mock_stat.call_count == 2
@@ -111,7 +111,7 @@ def test_apply_postprocessing(mock_stat, mock_smooth, caplog):
     caplog.clear()
 
     # no smoothing
-    _ = template_postprocessor.apply_postprocessing(
+    _ = postprocessor.apply_postprocessing(
         histogram, name, smoothing_algorithm=None, nominal_histogram=None
     )
     assert mock_stat.call_count == 3
@@ -121,14 +121,14 @@ def test_apply_postprocessing(mock_stat, mock_smooth, caplog):
     with pytest.raises(
         ValueError, match="cannot apply smoothing, nominal histogram missing"
     ):
-        _ = template_postprocessor.apply_postprocessing(
+        _ = postprocessor.apply_postprocessing(
             histogram, name, smoothing_algorithm="353QH, twice", nominal_histogram=None
         )
 
 
 @mock.patch("cabinetry.histo.name", return_value="histo_name")
 def test__postprocessor(mock_name):
-    postprocessor = template_postprocessor._postprocessor(pathlib.Path("path"))
+    processor = postprocessor._postprocessor(pathlib.Path("path"))
 
     region = {"Name": "region"}
     sample = {"Name": "sample"}
@@ -141,15 +141,15 @@ def test__postprocessor(mock_name):
         "cabinetry.histo.Histogram.from_config", return_value=mock_original_histogram
     ) as mock_from_config:
         with mock.patch(
-            "cabinetry.template_postprocessor.apply_postprocessing",
+            "cabinetry.templates.postprocessor.apply_postprocessing",
             return_value=mock_new_histogram,
         ) as mock_postprocessing:
             # execute the provided function
             with mock.patch(
-                "cabinetry.template_postprocessor._smoothing_algorithm",
+                "cabinetry.templates.postprocessor._smoothing_algorithm",
                 return_value=None,
             ) as mock_smooth:
-                postprocessor(region, sample, systematic, template)
+                processor(region, sample, systematic, template)
                 assert mock_smooth.call_args_list == [
                     ((region, sample, systematic), {})
                 ]
@@ -179,10 +179,10 @@ def test__postprocessor(mock_name):
                 "Smoothing": {"Algorithm": "353QH, twice"},
             }
             with mock.patch(
-                "cabinetry.template_postprocessor._smoothing_algorithm",
+                "cabinetry.templates.postprocessor._smoothing_algorithm",
                 return_value="353QH, twice",
             ) as mock_smooth:
-                postprocessor(region, sample, systematic, template)
+                processor(region, sample, systematic, template)
                 assert mock_smooth.call_args_list == [
                     ((region, sample, systematic), {})
                 ]
@@ -198,13 +198,3 @@ def test__postprocessor(mock_name):
                     "nominal_histogram": mock_original_histogram,
                 },
             )
-
-
-@mock.patch("cabinetry.route.apply_to_all_templates")
-@mock.patch("cabinetry.template_postprocessor._postprocessor", return_value="func")
-def test_run(mock_postprocessor, mock_apply):
-    config = {"General": {"HistogramFolder": "path/"}}
-    template_postprocessor.run(config)
-
-    assert mock_postprocessor.call_args_list == [((pathlib.Path("path/"),), {})]
-    assert mock_apply.call_args_list == [((config, "func"), {})]
diff --git a/tests/templates/test_templates_utils.py b/tests/templates/test_templates_utils.py
new file mode 100644
index 00000000..506b772a
--- /dev/null
+++ b/tests/templates/test_templates_utils.py
@@ -0,0 +1,38 @@
+import pathlib
+from unittest import mock
+
+from cabinetry.templates import utils
+
+
+def test__check_for_override():
+    # override exists for template
+    assert (
+        utils._check_for_override({"Up": {"setting": "val"}}, "Up", "setting") == "val"
+    )
+
+    # no override for template
+    assert utils._check_for_override({}, "Up", "setting") is None
+
+    # no option requested
+    assert utils._check_for_override({"Up": {"setting": "val"}}, "Up", "") is None
+
+    # override is a list
+    assert utils._check_for_override(
+        {"Up": {"setting": ["val", "val2"]}}, "Up", "setting"
+    ) == ["val", "val2"]
+
+
+@mock.patch("cabinetry.histo.name", return_value="name")
+def test__name_and_save(mock_name):
+    histogram_folder = pathlib.Path("path")
+    histogram = mock.MagicMock()
+    region = {"Name": "test_region"}
+    sample = {"Name": "sample"}
+    systematic = {}
+
+    utils._name_and_save(histogram_folder, histogram, region, sample, systematic, "Up")
+
+    # check that the naming function was called, the histogram was validated and saved
+    assert mock_name.call_args_list == [((region, sample, systematic, "Up"), {})]
+    assert histogram.validate.call_args_list == [mock.call("name")]
+    assert histogram.save.call_args_list == [mock.call(pathlib.Path("path/name"))]
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 54bc8a9c..fb0bb752 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -4,7 +4,12 @@
 import pytest
 
 import cabinetry
-from utils import create_ntuples
+from utils import create_histograms, create_ntuples
+
+
+@pytest.fixture
+def histogram_creator():
+    return create_histograms.run
 
 
 @pytest.fixture
@@ -23,12 +28,12 @@ def test_integration(tmp_path, ntuple_creator, caplog):
 
     # override config options to point to tmp_path
     cabinetry_config["General"]["HistogramFolder"] = str(tmp_path / "histograms")
-    cabinetry_config["General"]["InputPath"] = str(tmp_path / "{SamplePaths}")
+    cabinetry_config["General"]["InputPath"] = str(tmp_path / "{SamplePath}")
 
     caplog.set_level(logging.DEBUG)
 
-    cabinetry.template_builder.create_histograms(cabinetry_config, method="uproot")
-    cabinetry.template_postprocessor.run(cabinetry_config)
+    cabinetry.templates.build(cabinetry_config, method="uproot")
+    cabinetry.templates.postprocess(cabinetry_config)
     workspace_path = tmp_path / "example_workspace.json"
     ws = cabinetry.workspace.build(cabinetry_config)
     cabinetry.workspace.save(ws, workspace_path)
@@ -255,3 +260,39 @@ def test_integration(tmp_path, ntuple_creator, caplog):
     np.allclose(significance_results.observed_significance, 1.80118813)
     np.allclose(significance_results.expected_p_value, 0.14775040)
     np.allclose(significance_results.expected_significance, 1.04613046)
+
+
+@pytest.mark.no_cover
+def test_histogram_reading(tmp_path, histogram_creator):
+    histogram_creator(str(tmp_path))
+
+    cabinetry_config = cabinetry.configuration.load("utils/config_histograms.yml")
+
+    # override config options to point to tmp_path
+    cabinetry_config["General"]["HistogramFolder"] = str(tmp_path / "histograms")
+    cabinetry_config["General"]["InputPath"] = (
+        f"{tmp_path / 'histograms.root'}:"
+        f"{{RegionPath}}/{{SamplePath}}/{{VariationPath}}"
+    )
+
+    cabinetry.templates.collect(cabinetry_config, method="uproot")
+    cabinetry.templates.postprocess(cabinetry_config)
+    ws = cabinetry.workspace.build(cabinetry_config)
+    model, data = cabinetry.model_utils.model_and_data(ws)
+    fit_results = cabinetry.fit.fit(
+        model, data, minos="Signal_norm", goodness_of_fit=True
+    )
+
+    bestfit_expected = [
+        1.00102289,
+        0.98910429,
+        1.01970061,
+        0.98296235,
+        1.68953317,
+        -0.0880332,
+        -0.32457145,
+        -0.58582788,
+    ]
+    best_twice_nll_expected = 17.194205
+    assert np.allclose(fit_results.bestfit, bestfit_expected)
+    assert np.allclose(fit_results.best_twice_nll, best_twice_nll_expected)
diff --git a/tests/test_template_builder.py b/tests/test_template_builder.py
deleted file mode 100644
index 875fe383..00000000
--- a/tests/test_template_builder.py
+++ /dev/null
@@ -1,385 +0,0 @@
-import logging
-import pathlib
-from unittest import mock
-
-import boost_histogram as bh
-import numpy as np
-import pytest
-
-from cabinetry import template_builder
-
-
-def test__check_for_override():
-    # override exists for template
-    assert (
-        template_builder._check_for_override(
-            {"Up": {"setting": "val"}}, "Up", "setting"
-        )
-        == "val"
-    )
-
-    # no override for template
-    assert template_builder._check_for_override({}, "Up", "setting") is None
-
-    # no option requested
-    assert (
-        template_builder._check_for_override({"Up": {"setting": "val"}}, "Up", "")
-        is None
-    )
-
-    # override is a list
-    assert template_builder._check_for_override(
-        {"Up": {"setting": ["val", "val2"]}}, "Up", "setting"
-    ) == ["val", "val2"]
-
-
-def test__ntuple_paths(caplog):
-    # only general path, no override
-    assert template_builder._ntuple_paths("path.root", {}, {}, {}, None) == [
-        pathlib.Path("path.root")
-    ]
-
-    # general path with region and sample templates
-    assert (
-        template_builder._ntuple_paths(
-            "{RegionPath}/{SamplePaths}",
-            {"RegionPath": "region"},
-            {"SamplePaths": "sample.root"},
-            {},
-            None,
-        )
-        == [pathlib.Path("region/sample.root")]
-    )
-
-    # two SamplePaths
-    assert (
-        template_builder._ntuple_paths(
-            "{RegionPath}/{SamplePaths}",
-            {"RegionPath": "region"},
-            {"SamplePaths": ["sample.root", "new.root"]},
-            {},
-            None,
-        )
-        == [pathlib.Path("region/sample.root"), pathlib.Path("region/new.root")]
-    )
-
-    # systematic with override for RegionPath and SamplePaths
-    assert (
-        template_builder._ntuple_paths(
-            "{RegionPath}/{SamplePaths}",
-            {"RegionPath": "reg_1"},
-            {"SamplePaths": "path.root"},
-            {
-                "Name": "variation",
-                "Up": {
-                    "SamplePaths": ["variation.root", "new.root"],
-                    "RegionPath": "reg_2",
-                },
-            },
-            "Up",
-        )
-        == [pathlib.Path("reg_2/variation.root"), pathlib.Path("reg_2/new.root")]
-    )
-
-    # systematic without override
-    assert template_builder._ntuple_paths(
-        "{SamplePaths}", {}, {"SamplePaths": "path.root"}, {"Name": "variation"}, "Up"
-    ) == [pathlib.Path("path.root")]
-
-    caplog.set_level(logging.DEBUG)
-    caplog.clear()
-
-    # warning: no region path in template
-    assert template_builder._ntuple_paths(
-        "path.root", {"RegionPath": "region.root"}, {}, {}, None
-    ) == [pathlib.Path("path.root")]
-    assert "region override specified, but {RegionPath} not found in default path" in [
-        rec.message for rec in caplog.records
-    ]
-    caplog.clear()
-
-    # warning: no region path in template
-    assert template_builder._ntuple_paths(
-        "path.root", {}, {"SamplePaths": "sample.root"}, {}, None
-    ) == [pathlib.Path("path.root")]
-    assert "sample override specified, but {SamplePaths} not found in default path" in [
-        rec.message for rec in caplog.records
-    ]
-    caplog.clear()
-
-    # error: no override for {RegionPath}
-    with pytest.raises(ValueError, match="no path setting found for region region"):
-        template_builder._ntuple_paths("{RegionPath}", {"Name": "region"}, {}, {}, None)
-
-    # error: no override for {SamplePaths}
-    with pytest.raises(ValueError, match="no path setting found for sample sample"):
-        template_builder._ntuple_paths(
-            "{SamplePaths}", {}, {"Name": "sample"}, {}, None
-        )
-
-
-def test__variable():
-    # no override
-    assert template_builder._variable({"Variable": "jet_pt"}, {}, {}, None) == "jet_pt"
-
-    # systematic with override
-    assert (
-        template_builder._variable(
-            {"Variable": "jet_pt"},
-            {},
-            {"Name": "variation", "Up": {"Variable": "jet_pt_up"}},
-            "Up",
-        )
-        == "jet_pt_up"
-    )
-
-    # systematic without override
-    assert (
-        template_builder._variable(
-            {"Variable": "jet_pt"}, {}, {"Name": "variation"}, "Up"
-        )
-        == "jet_pt"
-    )
-
-
-def test__filter():
-    # no override
-    assert (
-        template_builder._filter({"Filter": "jet_pt > 0"}, {}, {}, None) == "jet_pt > 0"
-    )
-
-    # no filter
-    assert template_builder._filter({}, {}, {}, None) is None
-
-    # systematic with override
-    assert (
-        template_builder._filter(
-            {"Filter": "jet_pt > 0"},
-            {},
-            {"Name": "variation", "Up": {"Filter": "jet_pt > 100"}},
-            "Up",
-        )
-        == "jet_pt > 100"
-    )
-
-    # systematic without override
-    assert (
-        template_builder._filter(
-            {"Filter": "jet_pt > 0"}, {}, {"Name": "variation"}, "Up"
-        )
-        == "jet_pt > 0"
-    )
-
-    # sample-specific override
-    assert (
-        template_builder._filter(
-            {"Filter": "jet_pt > 0"}, {"Filter": "jet_pt > 100"}, {}, None
-        )
-        == "jet_pt > 100"
-    )
-
-    # sample-specific override, again overridden by systematic
-    assert (
-        template_builder._filter(
-            {"Filter": "jet_pt > 0"},
-            {"Filter": "jet_pt > 100"},
-            {"Name": "variation", "Up": {"Filter": "jet_pt > 200"}},
-            "Up",
-        )
-        == "jet_pt > 200"
-    )
-
-
-def test__weight():
-    # no override
-    assert (
-        template_builder._weight({}, {"Weight": "weight_mc"}, {}, None) == "weight_mc"
-    )
-
-    # no weight
-    assert template_builder._weight({}, {}, {}, None) is None
-
-    # systematic with override
-    assert (
-        template_builder._weight(
-            {},
-            {"Weight": "weight_mc"},
-            {"Name": "variation", "Up": {"Weight": "weight_modified"}},
-            "Up",
-        )
-        == "weight_modified"
-    )
-
-    # systematic without override
-    assert (
-        template_builder._weight(
-            {}, {"Weight": "weight_mc"}, {"Name": "variation"}, "Up"
-        )
-        == "weight_mc"
-    )
-
-
-def test__position_in_file():
-    # no override
-    assert (
-        template_builder._position_in_file({"Tree": "tree_name"}, {}, None)
-        == "tree_name"
-    )
-
-    # systematic with override
-    assert (
-        template_builder._position_in_file(
-            {"Tree": "nominal"}, {"Name": "variation", "Up": {"Tree": "up_tree"}}, "Up"
-        )
-        == "up_tree"
-    )
-
-    # systematic without override
-    assert (
-        template_builder._position_in_file(
-            {"Tree": "nominal"}, {"Name": "variation"}, "Up"
-        )
-        == "nominal"
-    )
-
-
-def test__binning():
-    np.testing.assert_equal(
-        template_builder._binning({"Binning": [1, 2, 3]}), [1, 2, 3]
-    )
-    with pytest.raises(NotImplementedError, match="cannot determine binning"):
-        template_builder._binning({})
-
-
-def test__Builder():
-    builder = template_builder._Builder(pathlib.Path("path"), "file.root", "uproot")
-    assert builder.histogram_folder == pathlib.Path("path")
-    assert builder.general_path == "file.root"
-    assert builder.method == "uproot"
-
-
-@mock.patch("cabinetry.template_builder._Builder._name_and_save")
-@mock.patch("cabinetry.histo.Histogram", return_value="cabinetry_histogram")
-@mock.patch(
-    "cabinetry.contrib.histogram_creation.from_uproot", return_value="histogram"
-)
-def test__Builder_create_histogram(mock_uproot_builder, mock_histo, mock_save):
-    # the binning [0] is not a proper binning, but simplifies the comparison
-    region = {"Name": "test_region", "Variable": "x", "Binning": [0], "Filter": "x>3"}
-    sample = {
-        "Name": "sample",
-        "Tree": "tree",
-        "SamplePaths": "path_to_sample",
-        "Weight": "weight_mc",
-    }
-    systematic = {}
-
-    builder = template_builder._Builder(pathlib.Path("path"), "{SamplePaths}", "uproot")
-    builder._create_histogram(region, sample, systematic, None)
-
-    # verify the backend call happened properly
-    assert mock_uproot_builder.call_args_list == [
-        (
-            ([pathlib.Path("path_to_sample")], "tree", "x", [0]),
-            {"weight": "weight_mc", "selection_filter": "x>3"},
-        )
-    ]
-
-    # conversion from bh.Histogram to cabinetry Histogram
-    assert mock_histo.call_args_list == [(("histogram",), {})]
-
-    # verify the call for saving wrapped histogram
-    assert mock_save.call_args_list == [
-        (("cabinetry_histogram", region, sample, systematic, None), {})
-    ]
-
-    # other backends
-    builder_unknown = template_builder._Builder(
-        pathlib.Path("path"), "{SamplePaths}", "unknown"
-    )
-    with pytest.raises(NotImplementedError, match="unknown backend unknown"):
-        builder_unknown._create_histogram(region, sample, systematic, None)
-
-
-@mock.patch("cabinetry.histo.name", return_value="name")
-def test__Builder__name_and_save(mock_name):
-    region = {"Name": "test_region"}
-    sample = {"Name": "sample"}
-    systematic = {}
-
-    histogram = mock.MagicMock()
-
-    builder = template_builder._Builder(pathlib.Path("path"), "file.root", "uproot")
-    builder._name_and_save(histogram, region, sample, systematic, "Up")
-
-    # check that the naming function was called, the histogram was validated and saved
-    assert mock_name.call_args_list == [((region, sample, systematic, "Up"), {})]
-    assert histogram.validate.call_args_list == [mock.call("name")]
-    assert histogram.save.call_args_list == [mock.call(pathlib.Path("path/name"))]
-
-
-@mock.patch("cabinetry.template_builder._Builder._name_and_save")
-def test__Builder__wrap_custom_template_builder(mock_save):
-    histogram = bh.Histogram(bh.axis.Variable([0, 1]))
-    region = {"Name": "test_region"}
-    sample = {"Name": "sample"}
-    systematic = {}
-
-    def test_func(reg, sam, sys, tem):
-        return histogram
-
-    builder = template_builder._Builder(pathlib.Path("path"), "file.root", "uproot")
-    wrapped_func = builder._wrap_custom_template_builder(test_func)
-
-    # check the behavior of the wrapped function
-    # when called, it should save the returned histogram
-    wrapped_func(region, sample, systematic, "Up")
-
-    assert mock_save.call_args_list == [
-        ((histogram, region, sample, systematic, "Up"), {})
-    ]
-
-    # wrapped function returns wrong type
-    def test_func_wrong_return(reg, sam, sys, tem):
-        return None
-
-    wrapped_func_wrong_return = builder._wrap_custom_template_builder(
-        test_func_wrong_return
-    )
-    with pytest.raises(TypeError, match="must return a boost_histogram.Histogram"):
-        wrapped_func_wrong_return(region, sample, systematic, "Up")
-
-
-def test_create_histograms():
-    config = {"General": {"HistogramFolder": "path/", "InputPath": "file.root"}}
-    method = "uproot"
-
-    # no router
-    with mock.patch("cabinetry.route.apply_to_all_templates") as mock_apply:
-        template_builder.create_histograms(config, method)
-        assert mock_apply.call_count == 1
-        config_call, func_call = mock_apply.call_args[0]
-        assert config_call == config
-        assert (
-            func_call.__name__ == "_create_histogram"
-        )  # could also compare to function
-        assert mock_apply.call_args[1] == {"match_func": None}
-
-    # including a router
-    mock_router = mock.MagicMock()
-    with mock.patch("cabinetry.route.apply_to_all_templates") as mock_apply:
-        template_builder.create_histograms(config, method, router=mock_router)
-
-        # verify wrapper was set
-        assert (
-            mock_router.template_builder_wrapper.__name__
-            == "_wrap_custom_template_builder"
-        )
-
-        assert mock_apply.call_count == 1
-        config_call, func_call = mock_apply.call_args[0]
-        assert config_call == config
-        assert func_call.__name__ == "_create_histogram"
-        assert mock_apply.call_args[1] == {
-            "match_func": mock_router._find_template_builder_match
-        }
diff --git a/tests/visualize/test_visualize.py b/tests/visualize/test_visualize.py
index d080bdc3..b1d9b6f2 100644
--- a/tests/visualize/test_visualize.py
+++ b/tests/visualize/test_visualize.py
@@ -1,4 +1,5 @@
 from collections import namedtuple
+import copy
 import pathlib
 from unittest import mock
 
@@ -129,10 +130,13 @@ def test_data_mc_from_histograms(mock_load, mock_draw, mock_stdev):
 @mock.patch(
     "cabinetry.visualize.plot_model.data_mc", return_value=matplotlib.figure.Figure()
 )
-@mock.patch("cabinetry.template_builder._binning", return_value=np.asarray([1, 2]))
+@mock.patch("cabinetry.templates.builder._binning", return_value=np.asarray([1, 2]))
 @mock.patch(
     "cabinetry.configuration.region_dict",
-    return_value={"Name": "region", "Variable": "x"},
+    side_effect=[
+        {"Name": "region", "Variable": "x", "Binning": [1, 2]},
+        {"Name": "region"},
+    ],
 )
 @mock.patch(
     "cabinetry.model_utils._filter_channels",
@@ -159,7 +163,9 @@ def test_data_mc(mock_data, mock_filter, mock_dict, mock_bins, mock_draw, exampl
     assert mock_data.call_args_list == [((model, data), {})]
     assert mock_filter.call_args_list == [((model, None), {})]
     assert mock_dict.call_args_list == [((config, "Signal Region"), {})]
-    assert mock_bins.call_args_list == [(({"Name": "region", "Variable": "x"},), {})]
+    assert mock_bins.call_args_list == [
+        (({"Name": "region", "Variable": "x", "Binning": [1, 2]},), {})
+    ]
 
     expected_histograms = [
         {
@@ -190,7 +196,8 @@ def test_data_mc(mock_data, mock_filter, mock_dict, mock_bins, mock_draw, exampl
     }
 
     # post-fit plot (different label in model prediction), custom scale, close figure,
-    # do not save figure
+    # do not save figure, histogram input mode: no binning or variable specified (via
+    # side effect)
     model_pred = model_utils.ModelPrediction(
         model, [[[11.0]]], [[0.2]], [0.2], "post-fit"
     )
@@ -206,11 +213,15 @@ def test_data_mc(mock_data, mock_filter, mock_dict, mock_bins, mock_draw, exampl
 
     assert mock_draw.call_count == 2
     # yield at best-fit point is different from pre-fit
-    assert np.allclose(mock_draw.call_args_list[1][0][0][0]["yields"], 11.0)
-    assert np.allclose(mock_draw.call_args_list[1][0][1], np.asarray([0.2]))
-    np.testing.assert_equal(mock_draw.call_args_list[1][0][2], np.asarray([1, 2]))
-    assert mock_draw.call_args_list[1][0][3] is None  # figure not saved
-    assert mock_draw.call_args_list[1][1] == {
+    assert np.allclose(mock_draw.call_args[0][0][0]["yields"], 11.0)
+    assert np.allclose(mock_draw.call_args[0][1], np.asarray([0.2]))
+    # observable defaults to "bin"
+    assert mock_draw.call_args[0][0][0]["variable"] == "bin"
+    assert mock_draw.call_args[0][0][1]["variable"] == "bin"
+    # binning falls back to default
+    np.testing.assert_equal(mock_draw.call_args[0][2], np.asarray([0, 1]))
+    assert mock_draw.call_args[0][3] is None  # figure not saved
+    assert mock_draw.call_args[1] == {
         "log_scale": False,
         "log_scale_x": False,
         "label": "Signal Region\npost-fit",
@@ -308,12 +319,15 @@ def test_templates(mock_draw, mock_histo_config, mock_histo_path, tmp_path):
         )
     ]
 
-    # close figure, do not save figure
+    # close figure, do not save figure, and remove variable information from config
+    # (simulating histogram inputs), so variable defaults to "observable"
+    histo_config = copy.deepcopy(config)
+    histo_config["Regions"] = [{"Name": "region"}]
     _ = visualize.templates(
-        config, figure_folder=folder_path, close_figure=True, save_figure=False
+        histo_config, figure_folder=folder_path, close_figure=True, save_figure=False
     )
     assert mock_draw.call_args == (
-        (nominal, up_orig, down_orig, up_mod, down_mod, bins, "x", None),
+        (nominal, up_orig, down_orig, up_mod, down_mod, bins, "observable", None),
         {
             "label": "region: region\nsample: sample\nsystematic: sys",
             "close_figure": True,
diff --git a/tests/visualize/test_plot_model.py b/tests/visualize/test_visualize_plot_model.py
similarity index 100%
rename from tests/visualize/test_plot_model.py
rename to tests/visualize/test_visualize_plot_model.py
diff --git a/tests/visualize/test_plot_result.py b/tests/visualize/test_visualize_plot_result.py
similarity index 100%
rename from tests/visualize/test_plot_result.py
rename to tests/visualize/test_visualize_plot_result.py
diff --git a/tests/visualize/test_utils.py b/tests/visualize/test_visualize_utils.py
similarity index 100%
rename from tests/visualize/test_utils.py
rename to tests/visualize/test_visualize_utils.py