Repository: ontodev/robot
Pull request #403 (2018-11-15 17:03:56)

Template Rework
This PR completely reworks the logic of `template`. Instead of using a static `TemplateOperation` class, it now uses a `Template` class. This allows for a cleaner way to add more features to template.

This rework also supports new template strings. I've included the documentation and examples below. I apologize in advance about the length 😅 TLDR:
- Property template strings
    - property logic (super properties, equivalents, disjoints, inverses)
    - extra property types (functional, inverse functional, symmetric, etc.)
- Individual template strings
    - property assertions (object and data)
    - same and different individual assertions
- Aliases for entity TYPE declarations
    - e.g. `object property` can be used instead of `owl:ObjectProperty`

@ontodev/robot-team This is an experimental update, so I would appreciate tests from anyone. The old template strings should produce the same results as before, even though the logic is different.

### Property Template Strings

- `PROPERTY_TYPE`: ROBOT creates a property for each row of data that has a `TYPE` of either an object or data property. The property type can be (any type followed by a \* can ONLY be used for object properties):
    - **logical types**: these types link the created property to other properties (annotation properties can *only* use `subproperty`)
      - `subproperty`: the created property will be a subproperty of each templated property expression (default)
      - `equivalent`: the created property will be equivalent to all of the templated property expressions
      - `disjoint`: the created property will be disjoint from each templated property expression and the values cannot be the same
      - `inverse`\*: the created object property will be the inverse of each templated property expression
    - **property types**: these types define the type of the created property, and will not work with annotation properties
      - `functional`: the created property will be functional, meaning each entity (subject) can have at most one value
      - `inverse functional`\*: the created object property will be inverse functional, meaning each value can have at most one subject
      - `irreflexive`\*: the created object property will be irreflexive, meaning the subject cannot also be the value
      - `reflexive`\*: the created object property will be reflexive, meaning each subject is also a value
      - `symmetric`\*: the created object property will be symmetric, meaning the subject and value can be reversed
      - `asymmetric`\*: the created object property will be asymmetric, meaning the subject and value cannot be reversed
      - `transitive`\*: the created object property will be transitive, meaning the property can be chained
- `P` **property expression**: If the template string starts with a `P` and a space then it will be interpreted as a property expression. The value of the current cell will be substituted into the template, replacing all occurrences of the `%` character. Then the result will be parsed into an OWL property expression. ROBOT uses the same syntax for property expressions as Protégé: [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax). If it does not recognize a name, ROBOT will assume that you're trying to refer to an entity by its IRI or CURIE. This can lead to unexpected behavior, but it allows you to refer to entities without loading them into the input ontology.
    - **object properties**: the only supported object property expression is the inverse object property expression. The template string is `P inverse(%)`. A single object property for a value can be specified by `P %`.
    - **data properties**: data property expressions are not yet supported by OWL. A data property for a value (e.g. for a parent property) can be specified by `P %`.
    - **annotation properties**: annotation property expressions are not possible. An annotation property for a value (e.g. for a parent property) can be specified by `P %`.
- `DOMAIN`: The domain to a property is a class expression in [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax) (for object and data properties). For annotation properties, the domain must be a single class specified by label, CURIE, or IRI.
- `RANGE`: The range to a property is either a class expression in [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax) (for object properties) or the name, CURIE, or IRI of a datatype (for annotation and data properties).

#### Example of Property Template Strings 

| **TYPE** | **PROPERTY_TYPE** | **P %** | **DOMAIN** | **RANGE** |
| --- | --- | --- | --- | --- |
| owl:ObjectProperty | subproperty | Property 1 | Class 1 | Class 2 |
| owl:DataProperty | functional | Property 2 | Class 2 | xsd:string |

The `functional` data property will still default to a `subproperty` logical axiom for the `P %` template string, unless a different logical property type (`equivalent`, `disjoint`) is provided. Property type can be split, e.g. `PROPERTY_TYPE SPLIT=|`.

### Individual Template Strings

- `INDIVIDUAL_TYPE`: ROBOT creates an individual for each or of data that has a `TYPE` of another class. The individual type can be:
    - `named`: the created individual will be a default named individual. When the `INDIVIDUAL_TYPE` is left blank, this is the default. This should be used when adding object property or data property assertions
    - `same`: the created individual will be asserted to be the same individual as each templated individual in the row
    - `different`: the created individual will be asserted to be a different individual than any of the templated individuals in the row
- `I` **individual assertion**:
    - `I property %`: when creating a `named` individual, replace property with an object property or data property to add assertions. The `%` will be replaced by the template cell value or values. For object property assertions, this is another individual. For data property assertions, this is a literal value.
    - `I %`: when creating a `same` or `different` individual, this template string is used to specify which individual will be the value of the same or different individual axiom.

#### Example of Individual Template Strings

| **INDIVIDUAL_TYPE** | **I 'Property 1' some %** | **I %** |
| --- | --- | --- |
| named | Individual 2 | |
| different | | Individual 1 | 

### Datatype Template Strings

*Datatypes can currently be added (`TYPE` = `owl:Datatype`), but I have not yet added support for [datatype definitions](https://www.w3.org/TR/owl2-syntax/#Datatype_Definitions). I'm still figuring out the best way to do this that wouldn't make the template strings too complex. There's nothing in the docs for this yet. Suggestions would be appreciated.*

### TYPE declarations

- `TYPE`: this is the `rdf:type` for the row. Because ROBOT is focused on ontology development, the default value is `owl:Class` and this column is optional. When creating an OWLIndividual, specify the class to which it belongs in this column.
    - `class` or `owl:Class`
    - `object property` or `owl:ObjectProperty`
    - `data property` or `owl:DataProperty`
    - `annotation property` or `owl:AnnotationProperty`
    - `datatype` or `owl:Datatype`


Patch:
diff --git a/docs/examples/reasoned.owl b/docs/examples/reasoned.owl
index c3897788..994663de 100644
--- a/docs/examples/reasoned.owl
+++ b/docs/examples/reasoned.owl
@@ -1,8 +1,8 @@
 <?xml version="1.0"?>
 <rdf:RDF xmlns="http://purl.obolibrary.org/obo/go.owl#"
      xml:base="http://purl.obolibrary.org/obo/go.owl"
-     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
      xmlns:owl="http://www.w3.org/2002/07/owl#"
+     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
      xmlns:xml="http://www.w3.org/XML/1998/namespace"
      xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
      xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#">
@@ -256,5 +256,5 @@
 
 
 
-<!-- Generated by the OWL API (version 0.0.1-SNAPSHOT) https://github.com/owlcs/owlapi -->
+<!-- Generated by the OWL API (version 4.5.6) https://github.com/owlcs/owlapi -->
 
diff --git a/docs/examples/template.csv b/docs/examples/template.csv
index 4932d6c8..10e1f11b 100644
--- a/docs/examples/template.csv
+++ b/docs/examples/template.csv
@@ -1,5 +1,6 @@
-ID,Label,Definition,Definition Source,See Also,Editor,RDF Type,Class Type,Parent IRI
-ID,A rdfs:label,A IAO:0000115,A IAO:0000119,AI rdfs:seeAlso,A IAO:0000117,TYPE,CLASS_TYPE,CI
-ex:F344N,F 344/N,An inbred strain of rat used in many scientific investigations.,James A. Overton,http://www.informatics.jax.org/external/festing/rat/docs/F344.shtml,James A. Overton,owl:Class,subclass,NCBITaxon:10116
-ex:B6C3F1,B6C3F1,An inbred strain of mouse used in many scientific investigations.,James A. Overton,http://jaxmice.jax.org/strain/100010.html,James A. Overton,owl:Class,subclass,NCBITaxon:10090
-ex:rat-1234,rat 1234,,,F 344/N,,ex:F344N,,
+ID,Label,Definition,Definition Source,See Also,Editor,Type,Parent Class,Disjoint Class,Domain,Range,Individual Weight
+ID,A rdfs:label,A IAO:0000115,>A IAO:0000119,AI rdfs:seeAlso,A IAO:0000117,TYPE,SC %,DC %,DOMAIN,RANGE,I weight in kilograms
+ex:F344N,F 344/N,An inbred strain of rat used in many scientific investigations.,James A. Overton,http://www.informatics.jax.org/external/festing/rat/docs/F344.shtml,James A. Overton,class,NCBITaxon:10116,,,,
+ex:B6C3F1,B6C3F1,An inbred strain of mouse used in many scientific investigations.,James A. Overton,http://jaxmice.jax.org/strain/100010.html,James A. Overton,class,NCBITaxon:10090,F 344/N,,,
+ex:propery-1,weight in kilograms,Weight of a mouse or rat in kilograms (kg).,Rebecca C Jackson,,Rebecca C Jackson,data property,,,'F 344/N' or B6C3F1,xsd:decimal,
+ex:rat-1234,rat 1234,,,,,F 344/N,,,,,0.2^^xsd:decimal
\ No newline at end of file
diff --git a/docs/examples/template.owl b/docs/examples/template.owl
index 8469fad7..1d9645ac 100644
--- a/docs/examples/template.owl
+++ b/docs/examples/template.owl
@@ -6,7 +6,8 @@
      xmlns:xml="http://www.w3.org/XML/1998/namespace"
      xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
      xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
-     xmlns:obo="http://purl.obolibrary.org/obo/">
+     xmlns:obo="http://purl.obolibrary.org/obo/"
+     xmlns:example="http://example.com/">
     <owl:Ontology rdf:about="https://github.com/ontodev/robot/examples/template.owl"/>
     
 
@@ -40,9 +41,39 @@
     
 
 
-    <!-- http://www.w3.org/1999/02/22-rdf-syntax-ns#type -->
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Data properties
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
 
-    <owl:AnnotationProperty rdf:about="http://www.w3.org/1999/02/22-rdf-syntax-ns#type"/>
+    
+
+
+    <!-- http://example.com/propery-1 -->
+
+    <owl:DatatypeProperty rdf:about="http://example.com/propery-1">
+        <rdfs:domain>
+            <owl:Class>
+                <owl:unionOf rdf:parseType="Collection">
+                    <rdf:Description rdf:about="http://example.com/B6C3F1"/>
+                    <rdf:Description rdf:about="http://example.com/F344N"/>
+                </owl:unionOf>
+            </owl:Class>
+        </rdfs:domain>
+        <rdfs:range rdf:resource="http://www.w3.org/2001/XMLSchema#decimal"/>
+        <obo:IAO_0000115 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">Weight of a mouse or rat in kilograms (kg).</obo:IAO_0000115>
+        <obo:IAO_0000117 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">Rebecca C Jackson</obo:IAO_0000117>
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">weight in kilograms</rdfs:label>
+    </owl:DatatypeProperty>
+    <owl:Axiom>
+        <owl:annotatedSource rdf:resource="http://example.com/propery-1"/>
+        <owl:annotatedProperty rdf:resource="http://purl.obolibrary.org/obo/IAO_0000115"/>
+        <owl:annotatedTarget rdf:datatype="http://www.w3.org/2001/XMLSchema#string">Weight of a mouse or rat in kilograms (kg).</owl:annotatedTarget>
+        <obo:IAO_0000119 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">Rebecca C Jackson</obo:IAO_0000119>
+    </owl:Axiom>
     
 
 
@@ -61,12 +92,18 @@
 
     <owl:Class rdf:about="http://example.com/B6C3F1">
         <rdfs:subClassOf rdf:resource="http://purl.obolibrary.org/obo/NCBITaxon_10090"/>
+        <owl:disjointWith rdf:resource="http://example.com/F344N"/>
         <obo:IAO_0000115 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">An inbred strain of mouse used in many scientific investigations.</obo:IAO_0000115>
         <obo:IAO_0000117 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000117>
-        <obo:IAO_0000119 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000119>
         <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">B6C3F1</rdfs:label>
         <rdfs:seeAlso rdf:resource="http://jaxmice.jax.org/strain/100010.html"/>
     </owl:Class>
+    <owl:Axiom>
+        <owl:annotatedSource rdf:resource="http://example.com/B6C3F1"/>
+        <owl:annotatedProperty rdf:resource="http://purl.obolibrary.org/obo/IAO_0000115"/>
+        <owl:annotatedTarget rdf:datatype="http://www.w3.org/2001/XMLSchema#string">An inbred strain of mouse used in many scientific investigations.</owl:annotatedTarget>
+        <obo:IAO_0000119 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000119>
+    </owl:Axiom>
     
 
 
@@ -76,10 +113,15 @@
         <rdfs:subClassOf rdf:resource="http://purl.obolibrary.org/obo/NCBITaxon_10116"/>
         <obo:IAO_0000115 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">An inbred strain of rat used in many scientific investigations.</obo:IAO_0000115>
         <obo:IAO_0000117 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000117>
-        <obo:IAO_0000119 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000119>
         <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">F 344/N</rdfs:label>
         <rdfs:seeAlso rdf:resource="http://www.informatics.jax.org/external/festing/rat/docs/F344.shtml"/>
     </owl:Class>
+    <owl:Axiom>
+        <owl:annotatedSource rdf:resource="http://example.com/F344N"/>
+        <owl:annotatedProperty rdf:resource="http://purl.obolibrary.org/obo/IAO_0000115"/>
+        <owl:annotatedTarget rdf:datatype="http://www.w3.org/2001/XMLSchema#string">An inbred strain of rat used in many scientific investigations.</owl:annotatedTarget>
+        <obo:IAO_0000119 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000119>
+    </owl:Axiom>
     
 
 
@@ -110,8 +152,8 @@
 
     <owl:NamedIndividual rdf:about="http://example.com/rat-1234">
         <rdf:type rdf:resource="http://example.com/F344N"/>
+        <example:propery-1 rdf:datatype="http://www.w3.org/2001/XMLSchema#decimal">0.2</example:propery-1>
         <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">rat 1234</rdfs:label>
-        <rdfs:seeAlso rdf:resource="http://example.com/F344N"/>
     </owl:NamedIndividual>
 </rdf:RDF>
 
diff --git a/docs/examples/uberon_template.csv b/docs/examples/uberon_template.csv
index 39be12e4..2cc896ef 100644
--- a/docs/examples/uberon_template.csv
+++ b/docs/examples/uberon_template.csv
@@ -1,4 +1,4 @@
 ID,Label,Definition,Definition Source,See Also,Editor,RDF Type,Class Type,Parent IRI
 ID,A rdfs:label,A IAO:0000115,A IAO:0000119,AI rdfs:seeAlso,A IAO:0000117,TYPE,CLASS_TYPE,CI
 UBERON:0000995,brain,The brain is the center of the nervous system in all vertebrate...,UBERON,http://en.wikipedia.org/wiki/Brain,Becky Tauber,owl:Class,subclass,UBERON:0000062
-UBERON:0000211,ligament,Dense regular connective tissue...,UBERON,http://purl.obolibrary.org/obo/go/references/0000034,Becky Tauber,owl:Class,subclass,UBERON:0000062
\ No newline at end of file
+UBERON:0000211,ligament,Dense regular connective tissue...,UBERON,http://purl.obolibrary.org/obo/go/references/0000034,Becky Tauber,owl:Class,subclass,UBERON:0000062
diff --git a/docs/template.md b/docs/template.md
index af17a665..dcfb70c2 100644
--- a/docs/template.md
+++ b/docs/template.md
@@ -1,7 +1,6 @@
-
 # Template
 
-ROBOT can convert tables to OWL format using templates. See <a href="/examples/template.csv" target="_blank">`template.csv`</a> for an example. The approach extends the QTT method described in <a href="http://dx.doi.org/10.3233/AO-2011-0086" target="_blank">Overcoming the ontology enrichment bottleneck with Quick Term Templates</a>.
+ROBOT can convert tables to OWL format using templates. See [`template.csv`](/examples/template.csv) for an example. The approach extends the QTT method described in [Overcoming the ontology enrichment bottleneck with Quick Term Templates](http://dx.doi.org/10.3233/AO-2011-0086).
 
 ROBOT can read comma-separated values (`.csv`) or tab-separated values (`.tsv` or `.tab`):
 
@@ -14,30 +13,128 @@ Each template file must be set up in the following format:
 
 1. **Headers**: ROBOT expects the first row to contain column names for every column used in the data. These are used to make error messages more helpful.
 2. **Templates**: ROBOT expects the second row to contain template strings for each column that will be used in the OWL conversion. See below for details on template strings.
-3. **Data**: ROBOT expects each of the remaining rows to correspond to an OWLClass or OWLIndividual. (In the future we may add support for other sorts of OWL entities). Rows with a blank "ID" column will be skipped.
+3. **Data**: ROBOT expects each of the remaining rows to correspond to a term (a class, property, or individual).
+
+### Template Options
+
+The `template` command accepts an optional input ontology, either using the `--input` option or from the previous command in a chain. If an input ontology is given, its `rdfs:label`s will be used when parsing the template. The `--template` or `-t` option specifies the CSV or TSV template file. Multiple templates are allowed, and the order of templates is significant. You can also specify the normal `--prefix` options, the `--output-iri` and `--version-iri`, and the usual `--output` options. See [Merging](/template#merging) for the three different merge options, and details on how they control the output of the command.
 
-The `template` command accepts an optional input ontology, either using the `--input` option or from the previous command in a chain. If an input ontology is given, its RDFS labels will be used when parsing the template. The `--template` or `-t` option specified the CSV or TSV template file. Multiple templates are allowed, and the order of templates is significant. You can also specify the normal `--prefix` options, the `--output-iri` and `--version-iri`, and the usual `--output` options. See below for the three different merge options, and details on how they control the output of the command.
+A template may have multiple errors in different rows and columns. By default, `template` will fail on the first error encountered. If you wish to proceed with errors, use `--force true`. This will log all row parse errors to STDERR and attempt to create an ontology anyway. Be aware that the output ontology may be missing axioms.
 
 ## Template Strings
 
-- `ID`: Every term must have an IRI to identify it. This can be specified with an `ID` column. Usually this will be a prefixed ID like `GO:12345`. See the `--prefix` options for details. Rows with an empty ID cell will be skipped.
-- `LABEL`: If a term exists in an ontology, or its ID has been defined elsewhere (perhaps in a previous template), then the `LABEL` column can specify an `rdfs:label` that uniquely identifies the target term. This can be easier the numeric IDs for human readers.
+### Generic Template Strings
+
+- `ID`: Every term must have an IRI to identify it. This can be specified with an `ID` column. Usually this will be a prefixed ID like `GO:12345`. See the `--prefix` options for details.
+    - If an entity already exists in the `--input` ontology, you can refer to it by `LABEL` instead (see below).
+    - Rows with no `ID` or `LABEL` will be skipped.
+- `LABEL`: a special keyword to specify an `rdfs:label` that uniquely identifies the target term. This can be easier than the numeric IDs for human readers. Keep in mind:
+    - The `LABEL` column will create `rdfs:label` string annotation for the entity
+    - If you are creating new entities using `LABEL`, be sure to include an `ID` column as well
 - `TYPE`: this is the `rdf:type` for the row. Because ROBOT is focused on ontology development, the default value is `owl:Class` and this column is optional. When creating an OWLIndividual, specify the class to which it belongs in this column.
-- `CLASS_TYPE`: ROBOT creates a class for each row of data. You must specify a CLASS_TYPE, which can be either:
-    - `subclass`: the created class will be asserted to be a subclass of each templated class expression
-    - `equivalent`: the created class will be asserted to be equivalent to the intersection of all the templated class expressions
+    - `class` or `owl:Class`
+    - `object property` or `owl:ObjectProperty`
+    - `data property` or `owl:DataProperty`
+    - `annotation property` or `owl:AnnotationProperty`
+    - `datatype` or `owl:Datatype`
+    - `individual`, `named individual`, `owl:Individual`, `owl:NamedIndividual`, or a defined class ID or label
 - **annotations**: ROBOT can attach annotations to your class. There are four options:
-    - `A` string annotation: If the template string starts with an `A` and a space then it will be interpreted as a string annotation. The rest of the template string should be the label or compact IRI of an annotation property, e.g. `label` or `rdfs:label`. The cell value will be the literal value of the annotation with type `xsd:string`.
+    - `A` string annotation: If the template string starts with an `A` and a space then it will be interpreted as a string annotation. The rest of the template string should be the label or compact IRI of an annotation property, e.g. `label` or `rdfs:label`. The cell value will be the literal value of the annotation with type `xsd:string`. Annotation property labels do not need to be wrapped in single quotes.
     - `AT` typed annotation: If the template string starts with an `AT` and a space then it will be interpreted as a typed annotation. The `^^` characters must be used to separate the annotation property from the datatype, e.g. `rdfs:comment^^xsd:integer`. The cell value will be the typed literal value of the annotation.
     - `AL` language annotation: If the template string starts with an `AL` and a space then it will be interpreted as a language annotation. The `@` character must be used to separate the annotation property from the language code, e.g. `rdfs:comment@en`.
     - `AI` annotation IRI: If the template string starts with an `AI` and a space, then the annotation will be made as with a string annotation, except that the cell value will be interpreted as an IRI.
-- `C` **class expression**: If the template string starts with a `C` and a space then it will be interpreted as a class expression. The value of the current cell will be substituted into the template, replacing all occurrences of the percent `%` character. Then the result will be parsed into an OWL class expression. ROBOT uses the same syntax for class expressions as Protégé: [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax). This means that an entity can be referred to by its rdfs:label (enclosing in single quotes if it has a space in it). If it does not recognize a label, ROBOT will assume that you're trying to refer to a class by its IRI (or compact IRI). This can lead to unexpected behaviour, but it allows you to refer to classes (by IRI) without loading them into the input ontology. This is particularly useful when the input ontology would be too large, such as the NCBI Taxonomy.
-- **axiom annotations**: ROBOT can also annotate logical and annotation axioms.
-    - `>A` annotation on annotation: Annotates the annotation axiom created from the cell to the left with the cell value. The column to the left must be an `A*` template string.
-    - `>C` annotation on class expression: Annotates the class expression axiom created from the cell to the left with the cell value. The column to the left must be a `C` template string.
-
-Sometimes you want to include zero or more values in a single spreadsheet cell, for example when you want to allow for multiple annotations or have seperate logical axioms. If a template string also contains `SPLIT=|`, then ROBOT will use the `|` character to split the contents of a cell in that column and add an annotation for each result (if there are any). Instead of `|` you can specify a string of characters of your choice - other than pure whitespace - to split on (e.g. `SPLIT=, `).
-
+- `>A` (**axiom annotations**): ROBOT can also annotate logical and annotation axioms. The axiom annotation will be on the axiom created on the cell to the left of the `>A*` template string. The `>` symbol can be used in front of any valid annotation character (`>A`, `>AT`, `>AL`, `>AI`)
+
+Sometimes you want to include zero or more values in a single spreadsheet cell, for example when you want to allow for multiple annotations or have separate logical axioms. If a template string also contains `SPLIT=|`, then ROBOT will use the `|` character to split the contents of a cell in that column and add an annotation for each result (if there are any). Instead of `|` you can specify a string of characters of your choice -- other than pure whitespace -- to split on (e.g. `SPLIT=, `).
+
+### Class Template Strings
+
+- **class expression**: If the template string starts with `C`, `SC`, `EC`, or `DC` followed by a space and the template string (e.g. `SC %`) then it will be interpreted as a class expression. The value of the current cell will be substituted into the template, replacing all occurrences of the percent `%` character. Then, the result will be parsed into an OWL class expression.
+    - ROBOT uses the same syntax for class expressions as Protégé: [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax). This means that an entity can be referred to by its `rdfs:label` (enclosing in single quotes if it has a space in it).
+    - If it does not recognize a label, ROBOT will assume that you're trying to refer to a class by its IRI (or compact IRI). This can lead to unexpected behaviour, but it allows you to refer to classes (by IRI) without loading them into the input ontology. This is particularly useful when the input ontology would be too large, such as the NCBI Taxonomy.
+    - Properties in class expressions **must** be referred to by label in order to be parsed.
+- `SC %`: the class will be asserted to be a subclass of the class expression in this column
+- `EC %`: the class will be asserted to be an equivalent class of the intersection of *all* `EC` class expressions in a row
+- `DC %`: the class will be asserted to be disjoint with the class expression in this column
+- `C %`: the class will be asserted as specified in the `CLASS_TYPE` column
+- `CLASS_TYPE`: an optional column that specifies the type for all `C` columns. This allows different rows to have different types of logical definitions. Valid values are:
+    - `subclass`: the values of all `C` columns will be asserted as subclasses (this is the default)
+    - `equivalent`: values of all `C` columns will be taken as an intersection and asserted to be an equivalent class
+    - `disjoint`: the values of all `C` columns will be asserted as disjoint classes
+
+(A `CI` template string tells ROBOT to read the cell value as an IRI and assert it as the `CLASS_TYPE`. This is included for legacy support, and the other class template strings are preferred.)
+
+#### Example of Class Template Strings
+
+| Label   | Entity Type | Superclass | Disjoint Classes | Equivalent axioms |
+| ------- | ----------- | ---------- | ---------------- | ----------------- |
+| LABEL   | TYPE        | SC %       | DC %             | EC part_of some % |
+| Class 2 | class       | Class 1    |                  |                   |
+| Class 3 | class       |            |                  | Class 2           |
+| Class 4 | class       | equivalent | Class 3          |                   |
+
+Class 2 will be a subclass of Class 1. Class 3 will be equivalent to `part_of some 'Class 2'` and Class 4 will be disjoint with Class 3.
+
+Manchester expressions can also be used within the cells. To avoid ambiguity, it's best to enclose expressions in parentheses:
+
+| Label   | Parent                   |
+| ------- | ------------------------ |
+| LABEL   | SC %                     |
+| Class 4 |                          |
+| Class 5 | (part_of some 'Class 4') |
+
+In this template, Class 5 would be a subclass of `part_of some 'Class 4'`.
+
+### Property Template Strings
+
+- `DOMAIN`: The domain to a property is a class expression in [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax) (for object and data properties). For annotation properties, the domain must be a single class specified by label, CURIE, or IRI.
+- `RANGE`: The range to a property is either a class expression in [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax) (for object properties) or the name, CURIE, or IRI of a datatype (for annotation and data properties).
+- `CHARACTERISTIC`: for each row of data that has a `TYPE` of object property or data property (*not* an annotation property), you can optionally specify a logical `CHARACTERISTIC` column. The column can be split, e.g. `CHARACTERISTIC SPLIT=|`, to specify multiple characteristics. Object properties can have any of the following characteristics, but only `functional` applies to data properties:
+    - `functional`: the created property will be functional, meaning each entity (subject) can have at most one value
+    - `inverse functional`: the created object property will be inverse functional, meaning each value can have at most one subject
+    - `reflexive`: the created object property will be reflexive, meaning each subject can also be a value
+    - `irreflexive`: the created object property will be irreflexive, meaning the subject cannot also be the value
+    - `symmetric`: the created object property will be symmetric, meaning the subject and value can be reversed
+    - `asymmetric`: the created object property will be asymmetric, meaning the subject and value cannot be reversed
+    - `transitive`: the created object property will be transitive, meaning the property can be chained
+- **property expression**: If the template string starts with `P`, `SP`, `EP`, `DP`, or `IP` followed by a space and the template string (e.g. `SP %`), then it will be interpreted as a property expression. The value of the current cell will be substituted into the template, replacing all occurrences of the `%` character. Then the result will be parsed into an OWL property expression. ROBOT uses the same syntax for property expressions as Protégé: [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax). If it does not recognize a name, ROBOT will assume that you're trying to refer to an entity by its IRI or CURIE. This can lead to unexpected behavior, but it allows you to refer to entities without loading them into the input ontology.
+    - `SP %`: the property will be asserted to be a subproperty of the property expression in the column
+    - `EP %`: the property will be asserted to be equivalent with the property expression in the column
+    - `DP %`: the property will be asserted to be disjoint with the property expression in the column
+    - `IP %`: the property will be asserted to be the inverse of the property expression in the column (this can only be used with object properties)
+    - `P %`: the property will be asserted as specified in the `PROPERTY_TYPE` column
+    - `PROPERTY_TYPE`: an optional column that specifies the type for all `P` columns. This allows different rows to have different types of logical definitions. Valid values are:
+        - `subproperty`: the values of all `P` columns will be asserted as subproperties (this is the default, annotation properties can only be subproperties)
+        - `equivalent`: values of all `C` columns will be asserted as equivalent properties
+        - `disjoint`: the values of all `P` columns will be asserted as disjoint properties
+        - `inverse`: the values of all `P` columns will be asserted as inverse properties (only applies to object properties)
+
+#### Example of Property Template Strings
+
+| ID   | Entity Type        | Characteristic | Super Property | Domain  | Range      |
+| ---- | ------------------ | -------------- | -------------- | ------- | ---------- |
+| ID   | TYPE               | CHARACTERISTIC | SP %           | DOMAIN  | RANGE      |
+| OP:1 | owl:ObjectProperty |                | Property 1     | Class 1 | Class 2    |
+| DP:1 | owl:DataProperty   | functional     | Property 2     | Class 2 | xsd:string |
+
+### Individual Template Strings
+
+If the `TYPE` is a defined class, `owl:Individual`, or `owl:NamedIndividual`, an instance will be created. If the `TYPE` does not include a defined class, that instance will have no class assertions. You may include a `SPLIT=` in `TYPE` if you wish to provide more than one class assertion for an individual.
+
+- **individual assertion**:
+    - `I <property>`: when creating an individual, replace property with an object property or data property to add assertions (either by label or CURIE). The value of each axiom will be the value of the cell in this column. For object property assertions, this is another individual. For data property assertions, this is a literal value. If using a property label here, **do not** wrap the label in single quotes.
+    - `SI %`: the individual in the column will be asserted to be the same individual
+    - `DI %`: the individual in the column will be asserted to be a different individual
+
+#### Example of Individual Template Strings
+
+| Label        | Entity Type | Property Assertions | Different Individuals |
+| ------------ | ----------- | ------------------- | --------------------- |
+| LABEL        | TYPE        | I part_of           | DI %                  |
+| Individual 1 | Class 1     | Individual 2        |                       |
+| Individual 2 | Class 1     |                     | Individual 1          |
+
+<!-- ### Datatype Template Strings -->
 
 ## Merging
 
@@ -61,7 +158,7 @@ merge after  | result     | merged
 
 These three options are particularly useful when chaining commands. For instance, the `merge-after` option lets you save the result ontology separately, then send the merged ontology to the next command. See [merge](/merge) for more information on merge options, including `--collapse-import-closure` and `--include-annotations`.
 
-If the command inclues `--ancestors`, the result ontology will include the ancestors (from the input ontology) of the result ontology terms. Only the labels of the ancestors will be included.
+If the command includes `--ancestors`, the result ontology will include the ancestors (from the input ontology) of the result ontology terms. Only the labels of the ancestors will be included.
 
 ## Examples
 
@@ -72,7 +169,7 @@ robot template --merge-before --input edit.owl \
   --template template.csv --output results/template.owl
 ```
 
-Create two outputs - the templated terms ([`uberon_template.owl`](/examples/uberon_template.owl)) and the input ontology merged with the output ontology with an annotation ([`uberon_v2.owl`](/examples/uberon_v2)):
+Create two outputs -- the templated terms ([`uberon_template.owl`](/examples/uberon_template.owl)) and the input ontology merged with the output ontology with an annotation ([`uberon_v2.owl`](/examples/uberon_v2)):
 <!-- DO NOT TEST -->
 ```
 robot template --merge-after \
@@ -110,13 +207,9 @@ Further examples can be found [in the OBI repository](https://github.com/obi-ont
 
 ## Error Messages
 
-### Missing Template Error
-
-You must specify at least one template with `--template` to proceed.
-
-### Merge Error
+### Annotation Property Characteristic Error
 
-`--merge-before` and `--merge-after` cannot be used simultaneously.
+Annotation properties should not have any value in the `CHARACTERISTIC` column, if it exists. This type of logic for annotation properties is not supported in OWL.
 
 ### Annotation Property Error
 
@@ -126,6 +219,34 @@ A rdfs:label
 A http://www.w3.org/2000/01/rdf-schema#label
 ```
 
+### Annotation Property Type Error
+
+The only valid `PROPERTY_TYPE` for an annotation property is `subproperty`. Other types of logic for annotation properties are not supported in OWL. If this column is left blank, it will default to `subproperty`.
+
+### Axiom Annotation Error
+
+An axiom annotation is an annotation on an axiom, either a class axiom or another annotation. Because of this, any time `>A` is used, an annotation must be in the previous column. Any time `>C` is used, a class expression must be in the previous column.
+```
+A rdfs:label,>A rdfs:comment
+C %,>C rdfs:comment
+```
+
+### Class Type Error
+
+The valid `CLASS_TYPE` values are: `subclass`, `equivalent`, and `disjoint`.
+
+### Class Type Split Error
+
+A class row may only use one of: `subclass`, `equivalent`, and `disjoint`. To add other types of axioms on an OWL class, use a separate row.
+
+### Column Mismatch Error
+
+There number of header columns (first row) must be equal to the number of template string columns (second row).
+
+### Data Property Characteristic Error
+
+The only valid `CHARACTERISTIC` value for a data property is `functional`. Other types of property characteristics for data properties are not supported in OWL.
+
 ### Datatype Error
 
 The datatype provided in an `AT` template string could not be resolved. Check your template to ensure the provided datatype is in a correct IRI or CURIE format. For legibility, using CURIEs is recommended, but you must ensure that the prefix is defined.
@@ -142,6 +263,14 @@ The `--template` option accepts the following file types: CSV, TSV, or TAB.
 
 Each template must have an ID column. Keep in mind that if the template has an ID column, but it is not filled in for a row, that row will be skipped.
 
+### Individual Type Error
+
+The valid `INDIVIDUAL_TYPE` values are: `named`, `same`, and `different`.
+
+### Individual Type Split Error
+
+An individual row may only use one of: `named`, `same`, and `different`. To add other types of axioms on an OWL individual, use a separate row.
+
 ### IRI Error
 
 The IRI provided as the value (in a row) to an `AI` template string could not be resolved as an IRI. Check your template to ensure the provided value is in a correct IRI or CURIE format. If using CURIEs, remember to ensure the prefix is defined.
@@ -153,45 +282,56 @@ The template string for an `AL` annotation must always include `@`.
 AL rdfs:label@en
 ```
 
-### Template File Error
-
-The template cannot be found in the current directory. Make sure the file exists and your path is correct.
-
-### Typed Format Error
+### Manchester Parse Error
 
-The template string for an `AT` annotation must always include `^^`.
-```
-AT rdfs:label^^xsd:string
-```
+The provided value cannot be parsed and may not be in proper Manchester syntax. See [Manchester Syntax](http://www.w3.org/2007/OWL/wiki/ManchesterSyntax) for more details. If you are using labels, make sure the labels are defined in the `--input` ontology or using the `LABEL` column. Also ensure that all properties use a label instead of a CURIE or IRI.
 
-### Axiom Annotation Error
+### Merge Error
 
-An axiom annotation is an annotation on an axiom, either a class axiom or another annotation. Because of this, any time `>A` is used, an annotation must be in the previous column. Any time `>C` is used, a class expression must be in the previous column.
-```
-A rdfs:label,>A rdfs:comment
-C %,>C rdfs:comment
-```
+`--merge-before` and `--merge-after` cannot be used simultaneously.
 
-### Column Mismatch Error
+### Missing Template Error
 
-There number of header columns (first row) must be equal to the number of template string columns (second row).
+You must specify at least one template with `--template` to proceed.
 
 ### Missing Type Error
 
 If no `CLASS_TYPE` column is included, ROBOT will default to using `subclass`. If a `CLASS_TYPE` column is included, though, each row must include a specified class type. If the `CLASS_TYPE` is left empty, this error message will be returned.
 
+### Multiple Property Type Error
+
+While the `PROPERTY_TYPE` column may include multiple types, only one of the logical types is allowed in each column: `subproperty`, `equivalent`, `disjoint`, or (for object properties only) `inverse`. To add other types of axioms on an OWL property, use a separate row.
+
 ### Null ID Error
 
 An IRI cannot be created from the provided ID. This is most likely because the ID is not formatted properly, as an IRI or a CURIE.
 
-### Parse Error
+### Property Type Error
 
-The content of a class expression cell (`C`) was not able to be parsed by OWLAPI. Check the formatting of the cell and the template string to ensure you are using valid CURIEs and/or IRIs.
+The valid `PROPERTY_TYPE` values are: `subproperty`, `equivalent`, `disjoint`, and (for object properties only) `inverse`.
 
-### Unknown Template Error
+### Template File Error
+
+The template cannot be found in the current directory. Make sure the file exists and your path is correct.
 
-Valid template strings are limited to the <a href="#template-strings">described above</a>. If a different template string is provided, this error message will be returned.
+### Typed Format Error
 
-### Unknown Type Error
+The template string for an `AT` annotation must always include `^^`.
+```
+AT rdfs:label^^xsd:string
+```
+
+### Unknown Characteristic Error
+
+An invalid `CHARACTERISTIC` value was passed. If you are providing multiple characteristics, make sure to include `SPLIT=` in your template string. Valid characteristics are:
+- `functional`
+- `inverse functional`
+- `reflexive`
+- `irreflexive`
+- `symmetric`
+- `asymmetric`
+- `transitive`
+
+### Unknown Template Error
 
-The `CLASS_TYPE` values are limited to `subclass` or `equivalent`. Anything else placed in that column will result in this error message.
+Valid template strings are limited to the [described above](#template-strings). If a different template string is provided, this error message will be returned.
diff --git a/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java b/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java
index c1917101..c474ac58 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java
@@ -1,10 +1,6 @@
 package org.obolibrary.robot;
 
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
+import java.util.*;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
 import org.semanticweb.owlapi.model.IRI;
@@ -51,6 +47,7 @@ public class TemplateCommand implements Command {
         "c", "collapse-import-closure", true, "if true, collapse the import closure when merging");
     o.addOption(
         "A", "include-annotations", true, "if true, include ontology annotations from merge input");
+    o.addOption("f", "force", true, "if true, do not exit on error");
 
     options = o;
   }
@@ -127,6 +124,14 @@ public class TemplateCommand implements Command {
     state = CommandLineHelper.updateInputOntology(ioHelper, state, line, false);
     OWLOntology inputOntology = state.getOntology();
 
+    // Override default reasoner options with command-line options
+    Map<String, String> templateOptions = TemplateOperation.getDefaultOptions();
+    for (String option : templateOptions.keySet()) {
+      if (line.hasOption(option)) {
+        templateOptions.put(option, line.getOptionValue(option));
+      }
+    }
+
     // Read the whole CSV into a nested list of strings.
     List<String> templatePaths = CommandLineHelper.getOptionalValues(line, "template");
     if (templatePaths.size() == 0) {
@@ -137,7 +142,9 @@ public class TemplateCommand implements Command {
       tables.put(templatePath, TemplateHelper.readTable(templatePath));
     }
 
-    OWLOntology outputOntology = TemplateOperation.template(tables, inputOntology, null, ioHelper);
+    // Process the templates
+    OWLOntology outputOntology =
+        TemplateOperation.template(inputOntology, ioHelper, tables, templateOptions);
 
     boolean collapseImports =
         CommandLineHelper.getBooleanValue(line, "collapse-import-closure", false);
@@ -149,7 +156,6 @@ public class TemplateCommand implements Command {
     // from the inputOntology, with just their labels.
     // Do not MIREOT the terms defined in the template,
     // just their dependencies!
-    List<OWLOntology> ontologies;
     boolean hasAncestors = CommandLineHelper.getBooleanValue(line, "ancestors", false, true);
     if (hasAncestors && inputOntology != null) {
       Set<IRI> iris = OntologyHelper.getIRIs(outputOntology);
@@ -157,29 +163,27 @@ public class TemplateCommand implements Command {
       OWLOntology ancestors =
           MireotOperation.getAncestors(
               inputOntology, null, iris, MireotOperation.getDefaultAnnotationProperties());
-      ontologies = new ArrayList<>();
-      ontologies.add(ancestors);
-      MergeOperation.mergeInto(ontologies, outputOntology, includeAnnotations, collapseImports);
+      MergeOperation.mergeInto(ancestors, outputOntology, includeAnnotations, collapseImports);
     }
 
     // Either merge-then-save, save-then-merge, or don't merge
-    ontologies = new ArrayList<>();
-    ontologies.add(outputOntology);
     boolean mergeBefore = CommandLineHelper.getBooleanValue(line, "merge-before", false, true);
     boolean mergeAfter = CommandLineHelper.getBooleanValue(line, "merge-after", false, true);
     if (mergeBefore && mergeAfter) {
       throw new IllegalArgumentException(mergeError);
     }
     if (mergeBefore) {
-      MergeOperation.mergeInto(ontologies, inputOntology, includeAnnotations, collapseImports);
+      MergeOperation.mergeInto(outputOntology, inputOntology, includeAnnotations, collapseImports);
       CommandLineHelper.maybeSaveOutput(line, inputOntology);
+      state.setOntology(inputOntology);
     } else if (mergeAfter) {
       CommandLineHelper.maybeSaveOutput(line, outputOntology);
-      MergeOperation.mergeInto(ontologies, inputOntology, includeAnnotations, collapseImports);
+      MergeOperation.mergeInto(outputOntology, inputOntology, includeAnnotations, collapseImports);
+      state.setOntology(inputOntology);
     } else {
       // Set ontology and version IRI
-      String ontologyIRI = CommandLineHelper.getOptionalValue(line, "ontology-iri");
       String versionIRI = CommandLineHelper.getOptionalValue(line, "version-iri");
+      String ontologyIRI = CommandLineHelper.getOptionalValue(line, "ontology-iri");
       if (ontologyIRI != null || versionIRI != null) {
         OntologyHelper.setOntologyIRI(outputOntology, ontologyIRI, versionIRI);
       }
diff --git a/robot-core/src/main/java/org/obolibrary/robot/QuotedEntityChecker.java b/robot-core/src/main/java/org/obolibrary/robot/QuotedEntityChecker.java
index fead3823..2b207cd1 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/QuotedEntityChecker.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/QuotedEntityChecker.java
@@ -70,6 +70,9 @@ public class QuotedEntityChecker implements OWLEntityChecker {
   /** Map from IRIs to names of entities. */
   private Map<IRI, String> labels = new HashMap<>();
 
+  /** Map from names to IRIs of entities. */
+  private Map<String, IRI> iris = new HashMap<>();
+
   /**
    * Add an IOHelper for resolving names to IRIs.
    *
@@ -211,6 +214,7 @@ public class QuotedEntityChecker implements OWLEntityChecker {
     if (providers != null) {
       for (ShortFormProvider provider : providers) {
         labels.put(entity.getIRI(), provider.getShortForm(entity));
+        iris.put(provider.getShortForm(entity), entity.getIRI());
         map.put(provider.getShortForm(entity), entity.getIRI());
       }
     }
@@ -226,6 +230,7 @@ public class QuotedEntityChecker implements OWLEntityChecker {
           // If it has a label, add it to the map (will replace short form)
           if (value != null) {
             labels.put(entity.getIRI(), value.getLiteral());
+            iris.put(value.getLiteral(), entity.getIRI());
             map.put(value.getLiteral(), entity.getIRI());
           }
         }
@@ -251,6 +256,7 @@ public class QuotedEntityChecker implements OWLEntityChecker {
     }
 
     labels.put(entity.getIRI(), name);
+    iris.put(name, entity.getIRI());
     map.put(name, entity.getIRI());
   }
 
@@ -277,45 +283,18 @@ public class QuotedEntityChecker implements OWLEntityChecker {
   }
 
   /**
-   * Get the IRI for the given name by checking all maps. If not found, create as a new IRI if
-   * create is true.
+   * Get the IRI for the given name, or create a new IRI from the name.
    *
-   * @param name the name of the entity to find the IRI for
-   * @param create when true and an IOHelper is defined, create the IRI
-   * @return the IRI of the entity or null if not found or created
+   * @param name entity to get IRI of, or to create
+   * @param create if true, create a new IRI
+   * @return the IRI of the entity, or null
    */
   public IRI getIRI(String name, boolean create) {
-    IRI iri = getIRI(classes, name);
-    if (iri != null) {
-      return iri;
-    }
-    iri = getIRI(annotationProperties, name);
-    if (iri != null) {
-      return iri;
-    }
-    iri = getIRI(dataProperties, name);
-    if (iri != null) {
-      return iri;
-    }
-    iri = getIRI(objectProperties, name);
-    if (iri != null) {
-      return iri;
-    }
-    iri = getIRI(namedIndividuals, name);
-    if (iri != null) {
-      return iri;
-    }
-    iri = getIRI(datatypes, name);
-    if (iri != null) {
-      return iri;
-    }
-    if (create && ioHelper != null) {
+    IRI iri = iris.getOrDefault(name, null);
+    if (iri == null && ioHelper != null && create) {
       iri = ioHelper.createIRI(name);
-      if (iri != null) {
-        return iri;
-      }
     }
-    return null;
+    return iri;
   }
 
   /**
@@ -332,28 +311,31 @@ public class QuotedEntityChecker implements OWLEntityChecker {
   }
 
   /**
-   * Find an annotation property with the given name. Quotation marks will be removed if necessary.
+   * Find an annotation property with the given name, or create one. Quotation marks will be removed
+   * if necessary.
    *
    * @param name the name of the entity to find
    * @return an annotation property, or null
    */
+  @Override
   public OWLAnnotationProperty getOWLAnnotationProperty(@Nonnull String name) {
-    return getOWLAnnotationProperty(name, false);
+    return getOWLAnnotationProperty(name, true);
   }
 
   /**
-   * Find an annotation property with the given name. Quotation marks will be removed if necessary.
+   * Find an annotation property with the given name, or create one. Quotation marks will be removed
+   * if necessary.
    *
    * @param name the name of the entity to find
-   * @param create when true and an IOHelper is defined, create the property
+   * @param create if false, do not create a new annotation property
    * @return an annotation property, or null
    */
-  public OWLAnnotationProperty getOWLAnnotationProperty(String name, boolean create) {
+  public OWLAnnotationProperty getOWLAnnotationProperty(@Nonnull String name, boolean create) {
     IRI iri = getIRI(annotationProperties, name);
     if (iri != null) {
       return dataFactory.getOWLAnnotationProperty(iri);
     }
-    if (ioHelper != null) {
+    if (create && ioHelper != null) {
       iri = ioHelper.createIRI(name);
       if (iri != null) {
         return dataFactory.getOWLAnnotationProperty(iri);
@@ -368,6 +350,7 @@ public class QuotedEntityChecker implements OWLEntityChecker {
    * @param name the name of the entity to find
    * @return a class, or null
    */
+  @Override
   public OWLClass getOWLClass(@Nonnull String name) {
     IRI iri = getIRI(classes, name);
     if (iri != null) {
@@ -388,22 +371,31 @@ public class QuotedEntityChecker implements OWLEntityChecker {
    * @param name the name of the entity to find
    * @return a data property, or null
    */
+  @Override
   public OWLDataProperty getOWLDataProperty(@Nonnull String name) {
     IRI iri = getIRI(dataProperties, name);
     if (iri != null) {
       return dataFactory.getOWLDataProperty(iri);
     }
+    if (ioHelper != null) {
+      iri = ioHelper.createIRI(name);
+      if (iri != null) {
+        return dataFactory.getOWLDataProperty(iri);
+      }
+    }
     return null;
   }
 
   /**
-   * Find a datatype with the given name. Quotation marks will be removed if necessary.
+   * Find a datatype with the given name, or create one. Quotation marks will be removed if
+   * necessary.
    *
    * @param name the name of the entity to find
    * @return a datatype, or null
    */
+  @Override
   public OWLDatatype getOWLDatatype(@Nonnull String name) {
-    return getOWLDatatype(name, false);
+    return getOWLDatatype(name, true);
   }
 
   /**
@@ -411,7 +403,7 @@ public class QuotedEntityChecker implements OWLEntityChecker {
    * necessary.
    *
    * @param name the name of the entity to find
-   * @param create when true and an IOHelper is defined, create the type
+   * @param create if false, do not create a new datatype
    * @return a datatype, or null
    */
   public OWLDatatype getOWLDatatype(@Nonnull String name, boolean create) {
@@ -434,11 +426,18 @@ public class QuotedEntityChecker implements OWLEntityChecker {
    * @param name the name of the entity to find
    * @return a named individual, or null
    */
+  @Override
   public OWLNamedIndividual getOWLIndividual(@Nonnull String name) {
     IRI iri = getIRI(namedIndividuals, name);
     if (iri != null) {
       return dataFactory.getOWLNamedIndividual(iri);
     }
+    if (ioHelper != null) {
+      iri = ioHelper.createIRI(name);
+      if (iri != null) {
+        return dataFactory.getOWLNamedIndividual(iri);
+      }
+    }
     return null;
   }
 
@@ -448,11 +447,18 @@ public class QuotedEntityChecker implements OWLEntityChecker {
    * @param name the name of the entity to find
    * @return an object property, or null
    */
+  @Override
   public OWLObjectProperty getOWLObjectProperty(@Nonnull String name) {
     IRI iri = getIRI(objectProperties, name);
     if (iri != null) {
       return dataFactory.getOWLObjectProperty(iri);
     }
+    if (ioHelper != null) {
+      iri = ioHelper.createIRI(name);
+      if (iri != null) {
+        return dataFactory.getOWLObjectProperty(iri);
+      }
+    }
     return null;
   }
 
diff --git a/robot-core/src/main/java/org/obolibrary/robot/Template.java b/robot-core/src/main/java/org/obolibrary/robot/Template.java
new file mode 100644
index 00000000..7bc3df0b
--- /dev/null
+++ b/robot-core/src/main/java/org/obolibrary/robot/Template.java
@@ -0,0 +1,2145 @@
+package org.obolibrary.robot;
+
+import java.util.*;
+import java.util.regex.Pattern;
+import javax.annotation.Nonnull;
+import org.obolibrary.robot.exceptions.ColumnException;
+import org.obolibrary.robot.exceptions.RowParseException;
+import org.semanticweb.owlapi.apibinding.OWLManager;
+import org.semanticweb.owlapi.manchestersyntax.parser.ManchesterOWLSyntaxClassExpressionParser;
+import org.semanticweb.owlapi.model.*;
+import org.semanticweb.owlapi.util.SimpleShortFormProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** @author <a href="mailto:rctauber@gmail.com">Becky Tauber</a> */
+public class Template {
+
+  /** Logger */
+  private static final Logger logger = LoggerFactory.getLogger(Template.class);
+
+  /** Template IOHelper to resolve prefixes. */
+  private IOHelper ioHelper;
+
+  /** Template QuotedEntityChecker to get entities and IRIs by label. */
+  private QuotedEntityChecker checker;
+
+  /** Manchester Syntax parser to parse class expressions. */
+  private ManchesterOWLSyntaxClassExpressionParser parser;
+
+  /** Set of axioms generated from template. */
+  private Set<OWLAxiom> axioms;
+
+  /** Name of the table. */
+  private String name;
+
+  /** Location of IDs (ID). */
+  private int idColumn = -1;
+
+  /** Location of labels (LABEL, A rdfs:label, A label). */
+  private int labelColumn = -1;
+
+  /** Location of entity types (TYPE). */
+  private int typeColumn = -1;
+
+  /** Location of class types (CLASS_TYPE). */
+  private int classTypeColumn = -1;
+
+  /** Location of property types (PROPERTY_TYPE). */
+  private int propertyTypeColumn = -1;
+
+  /** Location of property characteristic (CHARACTERISTIC). */
+  private int characteristicColumn = -1;
+
+  /** Location of individual types (INDIVIDUAL_TYPE). */
+  private int individualTypeColumn = -1;
+
+  /** Character to split property characteristics on. */
+  private String characteristicSplit = null;
+
+  /** Character to split generic types on. */
+  private String typeSplit = null;
+
+  /** List of human-readable template headers. */
+  private List<String> headers;
+
+  /** List of ROBOT template strings. */
+  private List<String> templates;
+
+  /** All other rows of the table (does not include headers and template strings). */
+  private List<List<String>> tableRows;
+
+  /** Row number tracker. Start with 2 to skip headers. */
+  private int rowNum = 2;
+
+  /** Shared data factory. */
+  private final OWLDataFactory dataFactory = OWLManager.getOWLDataFactory();
+
+  /** Namespace for error messages. */
+  private static final String NS = "template#";
+
+  /** Error message when an annotation property has a characteristic. */
+  private static final String annotationPropertyCharacteristicError =
+      NS
+          + "ANNOTATION PROPERTY CHARACTERISTIC ERROR annotation property '%s' should not have any characteristics at line %d, column %d in table \"%s\"";
+
+  /** Error message when an annotation property gets a property type other than subproperty. */
+  private static final String annotationPropertyTypeError =
+      NS
+          + "ANNOTATION PROPERTY TYPE ERROR annotation property %s type '%s' must be 'subproperty' at row %d, column %d in table \"%s\".";
+
+  /** Error message when an invalid class type is provided. */
+  private static final String classTypeError =
+      NS + "CLASS TYPE ERROR class %s has unknown type '%s' at row %d, column %d in table \"%s\".";
+
+  /** Error message when CLASS_TYPE has a SPLIT. */
+  private static final String classTypeSplitError =
+      NS
+          + "CLASS TYPE SPLIT ERROR the SPLIT functionality should not be used for CLASS_TYPE in column %d in table \"%s\".";
+
+  /**
+   * Error message when the number of header columns does not match the number of template columns.
+   * Expects: table name, header count, template count.
+   */
+  private static final String columnMismatchError =
+      NS
+          + "COLUMN MISMATCH ERROR the number of header columns (%2$d) must match the number of template columns (%3$d) in table \"%1$s\".";
+
+  /** Error message when a data property has a characteristic other than 'functional'. */
+  private static final String dataPropertyCharacteristicError =
+      NS
+          + "DATA PROPERTY CHARACTERISTIC ERROR data property '%s' can only have characteristic 'functional' at line %d, column %d in table \"%s\".";
+
+  /** Error message when an invalid individual type is provided. */
+  private static final String individualTypeError =
+      NS
+          + "INDIVIDUAL TYPE ERROR individual %s has unknown type '%s' at row %d, column %d in table \"%s\".";
+
+  /** Error message when INDIVIDUAL_TYPE has a SPLIT. */
+  private static final String individualTypeSplitError =
+      NS
+          + "INDIVIDUAL TYPE SPLIT ERROR the SPLIT functionality should not be used for INDIVIDUAL_TYPE in column %d in table \"%s\".";
+
+  /** Error message when an invalid property type is provided. */
+  private static final String propertyTypeError =
+      NS
+          + "PROPERTY TYPE ERROR property %s has unknown type '%s' at row %d, column %d in table \"%s\".";
+
+  /** Error message when more than one logical type is used in PROPERTY_TYPE. */
+  private static final String propertyTypeSplitError =
+      NS
+          + "PROPERTY TYPE SPLIT ERROR thee SPLIT functionality should not be used for PROPERTY_TYPE in column %d in table \"%s\".";
+
+  /** Error message when property characteristic not valid. */
+  private static final String unknownCharacteristicError =
+      NS
+          + "UNKNOWN CHARACTERISTIC ERROR property '%s' has unknown characteristic '%s' at line %d, column %d in table \"%s\".";
+
+  /**
+   * Error message when a template cannot be understood. Expects: table name, column number, column
+   * name, template.
+   */
+  private static final String unknownTemplateError =
+      NS
+          + "UNKNOWN TEMPLATE ERROR could not interpret template string \"%4$s\" for column %2$d (\"%3$s\") in table \"%1$s\".";
+
+  private static final List<String> validClassTypes =
+      new ArrayList<>(Arrays.asList("subclass", "disjoint", "equivalent"));
+
+  /**
+   * Given a template name and a list of rows, create a template object with a new IOHelper and
+   * QuotedEntityChecker. The rows are added to the object, new labels from the rows are added to
+   * the checker, and a Manchester Syntax parser is created.
+   *
+   * @param name template name
+   * @param rows list of rows (lists)
+   * @throws Exception on issue creating IOHelper or adding table to template object
+   */
+  public Template(@Nonnull String name, @Nonnull List<List<String>> rows) throws Exception {
+    this.name = name;
+    this.ioHelper = new IOHelper();
+    tableRows = new ArrayList<>();
+    templates = new ArrayList<>();
+    headers = new ArrayList<>();
+    axioms = new HashSet<>();
+
+    checker = new QuotedEntityChecker();
+    checker.setIOHelper(this.ioHelper);
+    checker.addProvider(new SimpleShortFormProvider());
+
+    // Add the contents of the tableRows
+    addTable(rows);
+    addLabels();
+    createParser();
+  }
+
+  /**
+   * Given a template name, a list of rows, and an IOHelper, create a template object with a new
+   * QuotedEntityChecker. The rows are added to the object, new labels from the rows are added to
+   * the checker, and a Manchester Syntax parser is created.
+   *
+   * @param name template name
+   * @param rows list of rows (lists)
+   * @param ioHelper IOHelper to resolve prefixes
+   * @throws Exception on issue adding table to template object
+   */
+  public Template(@Nonnull String name, @Nonnull List<List<String>> rows, IOHelper ioHelper)
+      throws Exception {
+    this.name = name;
+    this.ioHelper = ioHelper;
+    tableRows = new ArrayList<>();
+    templates = new ArrayList<>();
+    headers = new ArrayList<>();
+    axioms = new HashSet<>();
+
+    checker = new QuotedEntityChecker();
+    checker.setIOHelper(this.ioHelper);
+    checker.addProvider(new SimpleShortFormProvider());
+    checker.addProperty(dataFactory.getRDFSLabel());
+
+    // Add the contents of the tableRows
+    addTable(rows);
+    addLabels();
+    createParser();
+  }
+
+  /**
+   * Given a template name, a list of rows, and an input ontology, create a template object with a
+   * new IOHelper and QuotedEntityChecker populated by the input ontology. The rows are added to the
+   * object, new labels from the rows are added to the checker, and a Manchester Syntax parser is
+   * created.
+   *
+   * @param name template name
+   * @param rows list of rows (lists)
+   * @param inputOntology OWLOntology to get labels of entities for QuotedEntityChecker
+   * @throws Exception on issue creating IOHelper or adding table to template object
+   */
+  public Template(@Nonnull String name, @Nonnull List<List<String>> rows, OWLOntology inputOntology)
+      throws Exception {
+    this.name = name;
+    ioHelper = new IOHelper();
+    tableRows = new ArrayList<>();
+    templates = new ArrayList<>();
+    headers = new ArrayList<>();
+    axioms = new HashSet<>();
+
+    checker = new QuotedEntityChecker();
+    checker.setIOHelper(this.ioHelper);
+    checker.addProvider(new SimpleShortFormProvider());
+    checker.addProperty(dataFactory.getRDFSLabel());
+    if (inputOntology != null) {
+      checker.addAll(inputOntology);
+    }
+
+    // Add the contents of the tableRows
+    addTable(rows);
+    addLabels();
+    createParser();
+  }
+
+  /**
+   * Given a template name, a list of rows, an input ontology, and an IOHelper, create a template
+   * object with a new QuotedEntityChecker with the IOHelper populated by the input ontology. The
+   * rows are added to the object, new labels from the rows are added to the checker, and a
+   * Manchester Syntax parser is created.
+   *
+   * @param name template name
+   * @param rows list of rows (lists)
+   * @param inputOntology OWLOntology to get labels of entities for QuotedEntityChecker
+   * @param ioHelper IOHelper to resolve prefixes
+   * @throws Exception on issue adding table to template object
+   */
+  public Template(
+      @Nonnull String name,
+      @Nonnull List<List<String>> rows,
+      OWLOntology inputOntology,
+      IOHelper ioHelper)
+      throws Exception {
+    this.name = name;
+    this.ioHelper = ioHelper;
+    tableRows = new ArrayList<>();
+    templates = new ArrayList<>();
+    headers = new ArrayList<>();
+    axioms = new HashSet<>();
+
+    checker = new QuotedEntityChecker();
+    checker.setIOHelper(this.ioHelper);
+    checker.addProvider(new SimpleShortFormProvider());
+    checker.addProperty(dataFactory.getRDFSLabel());
+    if (inputOntology != null) {
+      checker.addAll(inputOntology);
+    }
+
+    // Add the contents of the tableRows
+    addTable(rows);
+    addLabels();
+    createParser();
+  }
+
+  /**
+   * Given a template name, a list of rows, an IOHelper, and a QuotedEntityChecker, create a
+   * template object. The rows are added to the object, new labels from the rows are added to the
+   * checker, and a Manchester Syntax parser is created.
+   *
+   * @param name template name
+   * @param rows list of rows (lists)
+   * @param inputOntology OWLOntology to get labels of entities for QuotedEntityChecker
+   * @param ioHelper IOHelper to resolve prefixes
+   * @param checker QuotedEntityChecker to get entities by label
+   * @throws Exception on issue adding table to template object
+   */
+  public Template(
+      @Nonnull String name,
+      @Nonnull List<List<String>> rows,
+      OWLOntology inputOntology,
+      IOHelper ioHelper,
+      QuotedEntityChecker checker)
+      throws Exception {
+    this.name = name;
+    this.ioHelper = ioHelper;
+    if (checker == null) {
+      this.checker = new QuotedEntityChecker();
+      this.checker.setIOHelper(this.ioHelper);
+      this.checker.addProvider(new SimpleShortFormProvider());
+      this.checker.addProperty(dataFactory.getRDFSLabel());
+    } else {
+      this.checker = checker;
+    }
+    tableRows = new ArrayList<>();
+    templates = new ArrayList<>();
+    headers = new ArrayList<>();
+    axioms = new HashSet<>();
+
+    if (inputOntology != null) {
+      this.checker.addAll(inputOntology);
+    }
+
+    // Add the contents of the tableRows
+    addTable(rows);
+    addLabels();
+    createParser();
+    parser.setOWLEntityChecker(this.checker);
+  }
+
+  /**
+   * Return the QuotedEntityChecker.
+   *
+   * @return QuotedEntityChecker
+   */
+  public QuotedEntityChecker getChecker() {
+    return checker;
+  }
+
+  /**
+   * Generate an OWLOntology based on the rows of the template.
+   *
+   * @return new OWLOntology
+   * @throws Exception on issue parsing rows to axioms or creating new ontology
+   */
+  public OWLOntology generateOutputOntology() throws Exception {
+    return generateOutputOntology(null, false);
+  }
+
+  /**
+   * Generate an OWLOntology with given IRI based on the rows of the template.
+   *
+   * @param outputIRI IRI for final ontology
+   * @param force if true, do not exit on errors
+   * @return new OWLOntology
+   * @throws Exception on issue parsing rows to axioms or creating new ontology
+   */
+  public OWLOntology generateOutputOntology(String outputIRI, boolean force) throws Exception {
+    // Set to true on first exception
+    boolean hasException = false;
+
+    for (List<String> row : tableRows) {
+      try {
+        processRow(row);
+      } catch (RowParseException e) {
+        // If force = false, fail on the first exception
+        if (!force) {
+          throw e;
+        }
+        // otherwise print exceptions as they show up
+        hasException = true;
+        logger.error(e.getMessage().substring(e.getMessage().indexOf("#") + 1));
+      }
+    }
+
+    if (hasException) {
+      logger.warn("Ontology created from template with errors");
+    }
+
+    // Create a new ontology object to add axioms to
+    OWLOntologyManager manager = OWLManager.createOWLOntologyManager();
+    OWLOntology outputOntology;
+    if (outputIRI != null) {
+      IRI iri = IRI.create(outputIRI);
+      outputOntology = manager.createOntology(iri);
+    } else {
+      outputOntology = manager.createOntology();
+    }
+
+    manager.addAxioms(outputOntology, axioms);
+
+    return outputOntology;
+  }
+
+  /**
+   * Given a list of rows for a table, first validate the headers and template strings. Then, get
+   * the location of important columns (e.g. IDs and labels). Finally, add all template rows to the
+   * object.
+   *
+   * @param rows list of rows (lists)
+   * @throws Exception on malformed template
+   */
+  private void addTable(List<List<String>> rows) throws Exception {
+    // Get and validate headers
+    headers = rows.get(0);
+    templates = rows.get(1);
+    if (headers.size() != templates.size()) {
+      throw new ColumnException(
+          String.format(columnMismatchError, name, headers.size(), templates.size()));
+    }
+
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+
+      // If the template is null or the column is empty, skip this column
+      if (template == null) {
+        continue;
+      }
+      template = template.trim();
+      if (template.isEmpty()) {
+        continue;
+      }
+
+      // Validate the template string
+      if (!TemplateHelper.validateTemplateString(template)) {
+        throw new ColumnException(
+            String.format(unknownTemplateError, name, column + 1, headers.get(column), template));
+      }
+
+      // Get the location of important columns
+      // If it is an annotation, check if it resolves to RDFS label
+      if (template.startsWith("A ")) {
+        String property = template.substring(2);
+        maybeSetLabelColumn(property, column);
+      } else if (template.startsWith("AT ")) {
+        String property;
+        if (template.contains("^^")) {
+          property = template.substring(3, template.indexOf("^^")).trim();
+        } else {
+          property = template.substring(3).trim();
+        }
+        maybeSetLabelColumn(property, column);
+      } else if (template.startsWith("AL ")) {
+        String property;
+        if (template.contains("@")) {
+          property = template.substring(3, template.indexOf("@")).trim();
+        } else {
+          property = template.substring(3).trim();
+        }
+        maybeSetLabelColumn(property, column);
+      } else if (template.startsWith("AI ")) {
+        String property = template.substring(3);
+        maybeSetLabelColumn(property, column);
+      } else if (template.equals("ID")) {
+        // Unique identifier (CURIE, IRI...)
+        idColumn = column;
+      } else if (template.equals("LABEL")) {
+        // Label identifier
+        labelColumn = column;
+      } else if (template.startsWith("TYPE")) {
+        // Entity type
+        typeColumn = column;
+        if (template.contains("SPLIT=")) {
+          typeSplit = template.substring(template.indexOf("SPLIT=") + 6);
+        }
+      } else if (template.startsWith("CLASS_TYPE")) {
+        // Class expression type
+        classTypeColumn = column;
+        if (template.contains("SPLIT=")) {
+          // Classes should only have one class type
+          throw new ColumnException(String.format(classTypeSplitError, column, name));
+        }
+      } else if (template.startsWith("PROPERTY_TYPE")) {
+        // Property expression type
+        propertyTypeColumn = column;
+        if (template.contains("SPLIT=")) {
+          // Instances should only have one individual type
+          throw new ColumnException(String.format(propertyTypeSplitError, column, name));
+        }
+      } else if (template.startsWith("INDIVIDUAL_TYPE")) {
+        // Individual expression type
+        individualTypeColumn = column;
+        if (template.contains("SPLIT=")) {
+          // Instances should only have one individual type
+          throw new ColumnException(String.format(individualTypeSplitError, column, name));
+        }
+      } else if (template.startsWith("CHARACTERISTIC")) {
+        // Property characteristic
+        characteristicColumn = column;
+        if (template.contains("SPLIT=")) {
+          characteristicSplit = template.substring(template.indexOf("SPLIT=") + 6);
+        }
+      }
+    }
+
+    // Each template needs a way to identify the entities
+    // Without one, we cannot continue
+    if (idColumn == -1 && labelColumn == -1) {
+      throw new ColumnException(
+          "Template row must include an \"ID\" or \"LABEL\" column in table: " + name);
+    }
+
+    // Add the rest of the tableRows to Template
+    for (int row = 2; row < rows.size(); row++) {
+      tableRows.add(rows.get(row));
+    }
+  }
+
+  /** Add the labels from the rows of the template to the QuotedEntityChecker. */
+  private void addLabels() {
+    // If there's no label column, we can't add labels
+    if (labelColumn == -1) {
+      return;
+    }
+    for (List<String> row : tableRows) {
+      String id = null;
+      if (idColumn != -1) {
+        try {
+          id = row.get(idColumn);
+        } catch (IndexOutOfBoundsException e) {
+          // ignore
+        }
+      }
+
+      String label = null;
+      try {
+        label = row.get(labelColumn);
+      } catch (IndexOutOfBoundsException e) {
+        // ignore
+      }
+
+      if (idColumn != -1 && id == null) {
+        continue;
+      }
+
+      if (id == null || label == null) {
+        continue;
+      }
+
+      String type = null;
+      if (typeColumn != -1) {
+        try {
+          type = row.get(typeColumn);
+        } catch (IndexOutOfBoundsException e) {
+          // ignore
+        }
+      }
+      if (type == null || type.trim().isEmpty()) {
+        type = "class";
+      }
+
+      IRI iri = ioHelper.createIRI(id);
+      if (iri == null) {
+        iri = IRI.create(id);
+      }
+
+      // Try to resolve a CURIE
+      IRI typeIRI = ioHelper.createIRI(type);
+
+      // Set to IRI string or to type string
+      String typeOrIRI = type;
+      if (typeIRI != null) {
+        typeOrIRI = typeIRI.toString();
+      }
+
+      OWLEntity entity;
+      switch (typeOrIRI) {
+        case "":
+        case "http://www.w3.org/2002/07/owl#Class":
+        case "class":
+          entity = dataFactory.getOWLEntity(EntityType.CLASS, iri);
+          break;
+
+        case "http://www.w3.org/2002/07/owl#ObjectProperty":
+        case "object property":
+          entity = dataFactory.getOWLEntity(EntityType.OBJECT_PROPERTY, iri);
+          break;
+
+        case "http://www.w3.org/2002/07/owl#DataProperty":
+        case "data property":
+          entity = dataFactory.getOWLEntity(EntityType.DATA_PROPERTY, iri);
+          break;
+
+        case "http://www.w3.org/2002/07/owl#AnnotationProperty":
+        case "annotation property":
+          entity = dataFactory.getOWLEntity(EntityType.ANNOTATION_PROPERTY, iri);
+          break;
+
+        case "http://www.w3.org/2002/07/owl#Individual":
+        case "individual":
+        case "http://www.w3.org/2002/07/owl#NamedIndividual":
+        case "named individual":
+          entity = dataFactory.getOWLEntity(EntityType.NAMED_INDIVIDUAL, iri);
+          break;
+
+        case "http://www.w3.org/2002/07/owl#Datatype":
+        case "datatype":
+          entity = dataFactory.getOWLEntity(EntityType.DATATYPE, iri);
+          break;
+
+        default:
+          // Assume type is an individual (checked later)
+          entity = dataFactory.getOWLEntity(EntityType.NAMED_INDIVIDUAL, iri);
+          break;
+      }
+      checker.add(entity, label);
+    }
+  }
+
+  /** Create a Manchester Syntax parser from the OWLDataFactory and QuotedEntityChecker. */
+  private void createParser() {
+    this.parser = new ManchesterOWLSyntaxClassExpressionParser(dataFactory, checker);
+  }
+
+  /**
+   * Process each of the table rows. First, get an entity based on ID or label. If the template
+   * contains an ID column, but it is empty, skip that row. If it does not contain an ID column,
+   * skip if the label is empty. Add axioms based on the entity type (class, object property, data
+   * property, annotation property, datatype, or individual).
+   *
+   * @throws Exception on issue creating axioms from template
+   */
+  private void processRow(List<String> row) throws Exception {
+    rowNum++;
+    String id = null;
+    try {
+      id = row.get(idColumn);
+    } catch (IndexOutOfBoundsException e) {
+      // ignore
+    }
+    String label = null;
+    try {
+      label = row.get(labelColumn);
+    } catch (IndexOutOfBoundsException e) {
+      // ignore
+    }
+    String type = null;
+    try {
+      type = row.get(typeColumn);
+    } catch (IndexOutOfBoundsException e) {
+      // ignore
+    }
+
+    // Skip if no ID and no label
+    if (id == null && label == null) {
+      return;
+    }
+
+    if (type == null || type.trim().isEmpty()) {
+      // Try to guess the type from already existing entities
+      if (label != null) {
+        OWLEntity e = checker.getOWLEntity(label);
+        if (e != null) {
+          type = e.getEntityType().getIRI().toString();
+        }
+      } else {
+        OWLEntity e = checker.getOWLEntity(id);
+        if (e != null) {
+          type = e.getEntityType().getIRI().toString();
+        }
+      }
+      // If the entity type is not defined
+      // and the entity does not already exist
+      // default to class
+      if (type == null) {
+        type = "class";
+      }
+    }
+
+    IRI iri = getIRI(id, label);
+    if (iri == null) {
+      return;
+    }
+
+    // Try to resolve a CURIE
+    IRI typeIRI = ioHelper.createIRI(type);
+
+    // Set to IRI string or to type string
+    String typeOrIRI = type;
+    if (typeIRI != null) {
+      typeOrIRI = typeIRI.toString();
+    }
+
+    switch (typeOrIRI) {
+      case "http://www.w3.org/2002/07/owl#Class":
+      case "class":
+        addClassAxioms(iri, row);
+        break;
+
+      case "http://www.w3.org/2002/07/owl#ObjectProperty":
+      case "object property":
+        addObjectPropertyAxioms(iri, row);
+        break;
+
+      case "http://www.w3.org/2002/07/owl#DataProperty":
+      case "data property":
+        addDataPropertyAxioms(iri, row);
+        break;
+
+      case "http://www.w3.org/2002/07/owl#AnnotationProperty":
+      case "annotation property":
+        addAnnotationPropertyAxioms(iri, row);
+        break;
+
+      case "http://www.w3.org/2002/07/owl#Datatype":
+      case "datatype":
+        addDatatypeAxioms(iri, row);
+        break;
+
+      case "http://www.w3.org/2002/07/owl#Individual":
+      case "individual":
+      case "http://www.w3.org/2002/07/owl#NamedIndividual":
+      case "named individual":
+      default:
+        addIndividualAxioms(iri, row);
+        break;
+    }
+  }
+
+  /* CLASS AXIOMS */
+
+  /**
+   * Given a class IRI and the row containing the class details, generate class axioms.
+   *
+   * @param iri class IRI
+   * @param row list of template values for given class
+   * @throws Exception on issue creating class axioms from template
+   */
+  private void addClassAxioms(IRI iri, List<String> row) throws Exception {
+    if (iri == null) {
+      return;
+    }
+    // Add the declaration
+    OWLClass cls = dataFactory.getOWLClass(iri);
+    OWLDeclarationAxiom ax = dataFactory.getOWLDeclarationAxiom(cls);
+    axioms.add(ax);
+
+    String classType = null;
+    if (classTypeColumn != -1) {
+      try {
+        classType = row.get(classTypeColumn);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+    }
+    if (classType == null || classType.trim().isEmpty()) {
+      classType = "subclass";
+    } else {
+      classType = classType.trim().toLowerCase();
+    }
+
+    if (!validClassTypes.contains(classType)) {
+      // Unknown class type
+      throw new RowParseException(
+          String.format(
+              classTypeError,
+              cls.getIRI().getShortForm(),
+              classType,
+              rowNum,
+              classTypeColumn,
+              name));
+    }
+
+    // Iterate through all columns and add annotations as we go
+    // Also collect any class expressions that will be used in logical definitions
+    // We collect all of these together so that equivalent expressions can be made into an
+    // intersection
+    // Instead of adding them in as we iterate through
+    Map<Integer, Set<OWLClassExpression>> subclassExpressionColumns = new HashMap<>();
+    Map<Integer, Set<OWLClassExpression>> equivalentExpressionColumns = new HashMap<>();
+    Map<Integer, Set<OWLClassExpression>> intersectionEquivalentExpressionColumns = new HashMap<>();
+    Map<Integer, Set<OWLClassExpression>> disjointExpressionColumns = new HashMap<>();
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      String value = null;
+      try {
+        value = row.get(column);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+
+      if (value == null || value.trim().isEmpty()) {
+        continue;
+      }
+
+      if (template.startsWith("A") || template.startsWith("LABEL")) {
+        // Handle class annotations
+        Set<OWLAnnotation> annotations = getAnnotations(template, value, row, column);
+        for (OWLAnnotation annotation : annotations) {
+          axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(iri, annotation));
+        }
+      } else if (template.startsWith("SC")) {
+        // Subclass expression
+        subclassExpressionColumns.put(
+            column,
+            TemplateHelper.getClassExpressions(name, parser, template, value, rowNum, column + 1));
+      } else if (template.startsWith("EC")) {
+        // Equivalent expression
+        equivalentExpressionColumns.put(
+            column,
+            TemplateHelper.getClassExpressions(name, parser, template, value, rowNum, column + 1));
+      } else if (template.startsWith("DC")) {
+        // Disjoint expression
+        disjointExpressionColumns.put(
+            column,
+            TemplateHelper.getClassExpressions(name, parser, template, value, rowNum, column + 1));
+      } else if (template.startsWith("C") && !template.startsWith("CLASS_TYPE")) {
+        // Use class type to determine what to do with the expression
+        switch (classType) {
+          case "subclass":
+            subclassExpressionColumns.put(
+                column,
+                TemplateHelper.getClassExpressions(
+                    name, parser, template, value, rowNum, column + 1));
+            break;
+          case "equivalent":
+            intersectionEquivalentExpressionColumns.put(
+                column,
+                TemplateHelper.getClassExpressions(
+                    name, parser, template, value, rowNum, column + 1));
+            break;
+          case "disjoint":
+            disjointExpressionColumns.put(
+                column,
+                TemplateHelper.getClassExpressions(
+                    name, parser, template, value, rowNum, column + 1));
+            break;
+          default:
+            break;
+        }
+      }
+    }
+
+    // Add the axioms
+    if (!subclassExpressionColumns.isEmpty()) {
+      addSubClassAxioms(cls, subclassExpressionColumns, row);
+    }
+    if (!equivalentExpressionColumns.isEmpty()) {
+      addEquivalentClassesAxioms(cls, equivalentExpressionColumns, row);
+    }
+    if (!intersectionEquivalentExpressionColumns.isEmpty()) {
+      // Special case to support legacy "C"/"equivalent" class type
+      // Which is the intersection of all C columns
+      addIntersectionEquivalentClassesAxioms(cls, intersectionEquivalentExpressionColumns, row);
+    }
+    if (!disjointExpressionColumns.isEmpty()) {
+      addDisjointClassAxioms(cls, disjointExpressionColumns, row);
+    }
+  }
+
+  /**
+   * Given an OWLClass, a map of column number to class expressions, and the row containing the
+   * class details, generate subClassOf axioms for the class where the parents are the class
+   * expressions. Maybe annotate the axioms.
+   *
+   * @param cls OWLClass to create subClassOf axioms for
+   * @param expressionColumns map of column numbers to sets of parent class expressions
+   * @param row list of template values for given class
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addSubClassAxioms(
+      OWLClass cls, Map<Integer, Set<OWLClassExpression>> expressionColumns, List<String> row)
+      throws Exception {
+    // Generate axioms
+    for (int column : expressionColumns.keySet()) {
+      // Maybe get an annotation on the expression
+      Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+      Set<OWLClassExpression> exprs = expressionColumns.get(column);
+      // Each expression will be its own subclass statement
+      for (OWLClassExpression expr : exprs) {
+        axioms.add(dataFactory.getOWLSubClassOfAxiom(cls, expr, axiomAnnotations));
+      }
+    }
+  }
+
+  /**
+   * Given an OWLClass, a map of column number to class expressions, and the row containing the
+   * class details, generate equivalent class axioms for the class where the equivalents are the
+   * class expressions. Maybe annotate the axioms.
+   *
+   * @param cls OWLClass to create equivalentClasses axiom for
+   * @param expressionColumns map of column number to equivalent class expression
+   * @param row list of template values for given class
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addEquivalentClassesAxioms(
+      OWLClass cls, Map<Integer, Set<OWLClassExpression>> expressionColumns, List<String> row)
+      throws Exception {
+    Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
+    Set<OWLClassExpression> expressions = new HashSet<>();
+    for (int column : expressionColumns.keySet()) {
+      // Maybe get an annotation on the expression (all annotations will be on the one intersection)
+      axiomAnnotations.addAll(maybeGetAxiomAnnotations(row, column));
+      // Add all expressions to the set of expressions
+      expressions.addAll(expressionColumns.get(column));
+    }
+    // Each expression will be its own equivalent class statement
+    for (OWLClassExpression expr : expressions) {
+      axioms.add(dataFactory.getOWLEquivalentClassesAxiom(cls, expr, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLClass, a map of column number to class expressions, and the row for this class,
+   * generate an equivalentClasses axiom for the class where the equivalent is the intersection of
+   * the provided class expressions. Maybe annotate the axioms.
+   *
+   * @param cls OWLClass to create equivalentClasses axiom for
+   * @param expressionColumns map of column number to equivalent class expression
+   * @param row list of template values for given class
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addIntersectionEquivalentClassesAxioms(
+      OWLClass cls, Map<Integer, Set<OWLClassExpression>> expressionColumns, List<String> row)
+      throws Exception {
+    Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
+    Set<OWLClassExpression> expressions = new HashSet<>();
+    for (int column : expressionColumns.keySet()) {
+      // Maybe get an annotation on the expression (all annotations will be on the one intersection)
+      axiomAnnotations.addAll(maybeGetAxiomAnnotations(row, column));
+      // Add all expressions to the set of expressions
+      expressions.addAll(expressionColumns.get(column));
+    }
+    // Create the axiom as an intersection of the provided expressions
+    OWLObjectIntersectionOf intersection = dataFactory.getOWLObjectIntersectionOf(expressions);
+    axioms.add(dataFactory.getOWLEquivalentClassesAxiom(cls, intersection, axiomAnnotations));
+  }
+
+  /**
+   * Given an OWLClass, a map of column number to class expressions, and the row containing the
+   * class details, generate disjointClasses axioms for the class where the disjoints are the class
+   * expressions. Maybe annotate the axioms.
+   *
+   * @param cls OWLClass to create disjointClasses axioms for
+   * @param expressionColumns map of column number to equivalent class expression
+   * @param row list of template values for given class
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addDisjointClassAxioms(
+      OWLClass cls, Map<Integer, Set<OWLClassExpression>> expressionColumns, List<String> row)
+      throws Exception {
+    for (int column : expressionColumns.keySet()) {
+      // Maybe get an annotation on the expression
+      Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+      Set<OWLClassExpression> exprs = expressionColumns.get(column);
+      // Each expression will be its own disjoint statement
+      for (OWLClassExpression expr : exprs) {
+        Set<OWLClassExpression> disjoint = new HashSet<>(Arrays.asList(cls, expr));
+        axioms.add(dataFactory.getOWLDisjointClassesAxiom(disjoint, axiomAnnotations));
+      }
+    }
+  }
+
+  /* OBJECT PROPERTY AXIOMS */
+
+  /**
+   * Given an object property IRI and the row containing the property details, generate property
+   * axioms.
+   *
+   * @param iri object property IRI
+   * @param row list of template values for given object property
+   * @throws Exception on issue creating object property axioms from template
+   */
+  private void addObjectPropertyAxioms(IRI iri, List<String> row) throws Exception {
+    // Add the declaration
+    axioms.add(
+        dataFactory.getOWLDeclarationAxiom(
+            dataFactory.getOWLEntity(EntityType.OBJECT_PROPERTY, iri)));
+
+    // Maybe get a property type (default subproperty)
+    String propertyType = getPropertyType(row);
+
+    // Maybe get characteristics (default none)
+    List<String> characteristics = getCharacteristics(row);
+
+    // Create the property object
+    OWLObjectProperty property = dataFactory.getOWLObjectProperty(iri);
+
+    // Handle special property types
+    for (String c : characteristics) {
+      switch (c.trim().toLowerCase()) {
+        case "asymmetric":
+          axioms.add(dataFactory.getOWLAsymmetricObjectPropertyAxiom(property));
+          break;
+        case "functional":
+          axioms.add(dataFactory.getOWLFunctionalObjectPropertyAxiom(property));
+          break;
+        case "inversefunctional":
+        case "inverse functional":
+          axioms.add(dataFactory.getOWLInverseFunctionalObjectPropertyAxiom(property));
+          break;
+        case "irreflexive":
+          axioms.add(dataFactory.getOWLIrreflexiveObjectPropertyAxiom(property));
+          break;
+        case "reflexive":
+          axioms.add(dataFactory.getOWLReflexiveObjectPropertyAxiom(property));
+          break;
+        case "symmetric":
+          axioms.add(dataFactory.getOWLSymmetricObjectPropertyAxiom(property));
+          break;
+        case "transitive":
+          axioms.add(dataFactory.getOWLTransitiveObjectPropertyAxiom(property));
+          break;
+        default:
+          throw new Exception(
+              String.format(
+                  unknownCharacteristicError,
+                  property.getIRI().getShortForm(),
+                  c,
+                  rowNum,
+                  characteristicColumn,
+                  name));
+      }
+    }
+
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      String value = null;
+      try {
+        value = row.get(column);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+
+      if (value == null || value.trim().isEmpty()) {
+        continue;
+      }
+
+      if (template.startsWith("A") || template.startsWith("LABEL")) {
+        // Handle annotations
+        Set<OWLAnnotation> annotations = getAnnotations(template, value, row, column);
+        for (OWLAnnotation annotation : annotations) {
+          axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(iri, annotation));
+        }
+      } else if (template.startsWith("SP")) {
+        // Subproperty expressions
+        Set<OWLObjectPropertyExpression> expressions =
+            TemplateHelper.getObjectPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        addSubObjectPropertyAxioms(property, expressions, row, column);
+      } else if (template.startsWith("EP")) {
+        // Equivalent properties expressions
+        Set<OWLObjectPropertyExpression> expressions =
+            TemplateHelper.getObjectPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        addEquivalentObjectPropertiesAxioms(property, expressions, row, column);
+      } else if (template.startsWith("DP")) {
+        // Disjoint properties expressions
+        Set<OWLObjectPropertyExpression> expressions =
+            TemplateHelper.getObjectPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        addDisjointObjectPropertiesAxioms(property, expressions, row, column);
+      } else if (template.startsWith("IP")) {
+        // Inverse properties expressions
+        Set<OWLObjectPropertyExpression> expressions =
+            TemplateHelper.getObjectPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        addInverseObjectPropertiesAxioms(property, expressions, row, column);
+      } else if (template.startsWith("P") && !template.startsWith("PROPERTY_TYPE")) {
+        // Use the property type to determine what type of expression
+        Set<OWLObjectPropertyExpression> expressions =
+            TemplateHelper.getObjectPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        switch (propertyType) {
+          case "subproperty":
+            addSubObjectPropertyAxioms(property, expressions, row, column);
+            break;
+          case "equivalent":
+            addEquivalentObjectPropertiesAxioms(property, expressions, row, column);
+            break;
+          case "disjoint":
+            addDisjointObjectPropertiesAxioms(property, expressions, row, column);
+            break;
+          case "inverse":
+            addInverseObjectPropertiesAxioms(property, expressions, row, column);
+            break;
+          default:
+            // Unknown property type
+            throw new RowParseException(
+                String.format(
+                    propertyTypeError, iri.getShortForm(), propertyType, rowNum, column + 1, name));
+        }
+      } else if (template.startsWith("DOMAIN")) {
+        // Handle domains
+        Set<OWLClassExpression> expressions =
+            TemplateHelper.getClassExpressions(name, parser, template, value, rowNum, column);
+        addObjectPropertyDomains(property, expressions, row, column);
+      } else if (template.startsWith("RANGE")) {
+        // Handle ranges
+        Set<OWLClassExpression> expressions =
+            TemplateHelper.getClassExpressions(name, parser, template, value, rowNum, column);
+        addObjectPropertyRanges(property, expressions, row, column);
+      }
+    }
+  }
+
+  /**
+   * Given an OWLObjectProperty, a set of OWLObjectPropertyExpressions, and the row containing the
+   * property details, generate subPropertyOf axioms for the property where the parents are the
+   * property expressions. Maybe annotate the axioms.
+   *
+   * @param property OWLObjectProperty to create subPropertyOf axioms for
+   * @param expressions set of OWLObjectPropertyExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addSubObjectPropertyAxioms(
+      OWLObjectProperty property,
+      Set<OWLObjectPropertyExpression> expressions,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLObjectPropertyExpression expr : expressions) {
+      axioms.add(dataFactory.getOWLSubObjectPropertyOfAxiom(property, expr, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLObjectProperty, a set of OWLObjectPropertyExpressions, and the row containing the
+   * property details, generate equivalentProperties axioms for the property where the equivalents
+   * are the property expressions. Maybe annotate the axioms.
+   *
+   * @param property OWLObjectProperty to create equivalentProperties axioms for
+   * @param expressions set of equivalent OWLObjectPropertyExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addEquivalentObjectPropertiesAxioms(
+      OWLObjectProperty property,
+      Set<OWLObjectPropertyExpression> expressions,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLObjectPropertyExpression expr : expressions) {
+      axioms.add(
+          dataFactory.getOWLEquivalentObjectPropertiesAxiom(property, expr, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLObjectProperty, a set of OWLObjectPropertyExpressions, and the row containing the
+   * property details, generate disjointProperties axioms for the property where the disjoints are
+   * the property expressions. Maybe annotate the axioms.
+   *
+   * @param property OWLObjectProperty to create disjointProperties axioms for
+   * @param expressions set of disjoint OWLObjectPropertyExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addDisjointObjectPropertiesAxioms(
+      OWLObjectProperty property,
+      Set<OWLObjectPropertyExpression> expressions,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    expressions.add(property);
+    axioms.add(dataFactory.getOWLDisjointObjectPropertiesAxiom(expressions, axiomAnnotations));
+  }
+
+  /**
+   * Given an OWLObjectProperty, a set of OWLObjectPropertyExpressions, and the row containing the
+   * property details, generate inverseProperties axioms for the property where the inverses are the
+   * property expressions. Maybe annotate the axioms.
+   *
+   * @param property OWLObjectProperty to create inverseProperties axioms for
+   * @param expressions set of inverse OWLObjectPropertyExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addInverseObjectPropertiesAxioms(
+      OWLObjectProperty property,
+      Set<OWLObjectPropertyExpression> expressions,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLObjectPropertyExpression expr : expressions) {
+      axioms.add(dataFactory.getOWLInverseObjectPropertiesAxiom(property, expr, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLObjectProperty, a set of OWLClassExpressions, the row containing the property
+   * details, and a column location, generate domain axioms where the domains are the class
+   * expressions. Maybe annotation the axioms.
+   *
+   * @param property OWLObjectProperty to create domain axioms for
+   * @param expressions set of domain OWLClassExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addObjectPropertyDomains(
+      OWLObjectProperty property, Set<OWLClassExpression> expressions, List<String> row, int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLClassExpression expr : expressions) {
+      axioms.add(dataFactory.getOWLObjectPropertyDomainAxiom(property, expr, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLObjectProperty, a set of OWLClassExpressions, the row containing the property
+   * details, and a column location, generate range axioms where the ranges are the class
+   * expressions. Maybe annotation the axioms.
+   *
+   * @param property OWLObjectProperty to create range axioms for
+   * @param expressions set of range OWLClassExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addObjectPropertyRanges(
+      OWLObjectProperty property, Set<OWLClassExpression> expressions, List<String> row, int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLClassExpression expr : expressions) {
+      axioms.add(dataFactory.getOWLObjectPropertyRangeAxiom(property, expr, axiomAnnotations));
+    }
+  }
+
+  /* DATA PROPERTY AXIOMS */
+
+  /**
+   * Given an data property IRI and the row containing the property details, generate property
+   * axioms.
+   *
+   * @param iri data property IRI
+   * @param row list of template values for given data property
+   * @throws Exception on issue creating data property axioms from template
+   */
+  private void addDataPropertyAxioms(IRI iri, List<String> row) throws Exception {
+    // Add the declaration
+    axioms.add(
+        dataFactory.getOWLDeclarationAxiom(
+            dataFactory.getOWLEntity(EntityType.DATA_PROPERTY, iri)));
+
+    OWLDataProperty property = dataFactory.getOWLDataProperty(iri);
+
+    // Maybe get property type (default subproperty)
+    String propertyType = getPropertyType(row);
+
+    // Maybe get property characteristics (default empty list)
+    List<String> characteristics = getCharacteristics(row);
+
+    // Maybe add property characteristics
+    for (String c : characteristics) {
+      if (!c.equalsIgnoreCase("functional")) {
+        throw new Exception(
+            String.format(
+                dataPropertyCharacteristicError,
+                property.getIRI().getShortForm(),
+                rowNum,
+                characteristicColumn,
+                name));
+      }
+      axioms.add(dataFactory.getOWLFunctionalDataPropertyAxiom(property));
+    }
+
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      String value = null;
+      try {
+        value = row.get(column);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+
+      if (value == null || value.trim().isEmpty()) {
+        continue;
+      }
+
+      String split = null;
+      if (template.contains("SPLIT=")) {
+        split = template.substring(template.indexOf("SPLIT=") + 6).trim();
+      }
+
+      if (template.startsWith("A") || template.startsWith("LABEL")) {
+        // Handle annotations
+        Set<OWLAnnotation> annotations = getAnnotations(template, value, row, column);
+        for (OWLAnnotation annotation : annotations) {
+          axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(iri, annotation));
+        }
+      } else if (template.startsWith("SP")) {
+        // Subproperty expressions
+        Set<OWLDataPropertyExpression> expressions =
+            TemplateHelper.getDataPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        addSubDataPropertyAxioms(property, expressions, row, column);
+      } else if (template.startsWith("EP")) {
+        // Equivalent properties expressions
+        Set<OWLDataPropertyExpression> expressions =
+            TemplateHelper.getDataPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        addEquivalentDataPropertiesAxioms(property, expressions, row, column);
+      } else if (template.startsWith("DP")) {
+        // Disjoint properties expressions
+        Set<OWLDataPropertyExpression> expressions =
+            TemplateHelper.getDataPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        addDisjointDataPropertiesAxioms(property, expressions, row, column);
+      } else if (template.startsWith("IP")) {
+        // Cannot use inverse with data properties
+        throw new RowParseException(
+            String.format(
+                propertyTypeError, iri.getShortForm(), propertyType, rowNum, column + 1, name));
+      } else if (template.startsWith("P ") && !template.startsWith("PROPERTY_TYPE")) {
+        // Use property type to handle expression type
+        Set<OWLDataPropertyExpression> expressions =
+            TemplateHelper.getDataPropertyExpressions(
+                name, checker, template, value, rowNum, column);
+        switch (propertyType) {
+          case "subproperty":
+            addSubDataPropertyAxioms(property, expressions, row, column);
+            break;
+          case "equivalent":
+            addEquivalentDataPropertiesAxioms(property, expressions, row, column);
+            break;
+          case "disjoint":
+            addDisjointDataPropertiesAxioms(property, expressions, row, column);
+            break;
+          default:
+            // Unknown property type
+            throw new RowParseException(
+                String.format(
+                    propertyTypeError, iri.getShortForm(), propertyType, rowNum, column + 1, name));
+        }
+      } else if (template.startsWith("DOMAIN")) {
+        // Handle domains
+        Set<OWLClassExpression> expressions =
+            TemplateHelper.getClassExpressions(name, parser, template, value, rowNum, column);
+        addDataPropertyDomains(property, expressions, row, column);
+      } else if (template.startsWith("RANGE")) {
+        // Handle ranges
+        Set<OWLDatatype> datatypes =
+            TemplateHelper.getDatatypes(name, checker, value, split, rowNum, column);
+        addDataPropertyRanges(property, datatypes, row, column);
+      }
+    }
+  }
+
+  /**
+   * Given an OWLDataProperty, a set of OWLDataPropertyExpressions, and the row containing the
+   * property details, generate subPropertyOf axioms for the property where the parents are the
+   * property expressions. Maybe annotate the axioms.
+   *
+   * @param property OWLDataProperty to create subPropertyOf axioms for
+   * @param expressions set of parent OWLDataPropertyExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addSubDataPropertyAxioms(
+      OWLDataProperty property,
+      Set<OWLDataPropertyExpression> expressions,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLDataPropertyExpression expr : expressions) {
+      if (expr != null) {
+        axioms.add(dataFactory.getOWLSubDataPropertyOfAxiom(property, expr, axiomAnnotations));
+      }
+    }
+  }
+
+  /**
+   * Given an OWLDataProperty, a set of OWLDataPropertyExpressions, and the row containing the
+   * property details, generate equivalentProperties axioms for the property where the equivalents
+   * are the property expressions. Maybe annotate the axioms.
+   *
+   * @param property OWLDataProperty to create equivalentProperties axioms for
+   * @param expressions set of equivalent OWLDataPropertyExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addEquivalentDataPropertiesAxioms(
+      OWLDataProperty property,
+      Set<OWLDataPropertyExpression> expressions,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLDataPropertyExpression expr : expressions) {
+      axioms.add(dataFactory.getOWLEquivalentDataPropertiesAxiom(property, expr, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLDataProperty, a set of OWLObjectPropertyExpressions, and the row containing the
+   * property details, generate disjointProperties axioms for the property where the disjoints are
+   * the property expressions. Maybe annotate the axioms.
+   *
+   * @param property OWLDataProperty to create disjointProperties axioms for
+   * @param expressions set of disjoint OWLDataPropertyExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addDisjointDataPropertiesAxioms(
+      OWLDataProperty property,
+      Set<OWLDataPropertyExpression> expressions,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    expressions.add(property);
+    axioms.add(dataFactory.getOWLDisjointDataPropertiesAxiom(expressions, axiomAnnotations));
+  }
+
+  /**
+   * Given an OWLDataProperty, a set of OWLClassExpressions, the row containing the property
+   * details, and a column location, generate domain axioms where the domains are the class
+   * expressions. Maybe annotation the axioms.
+   *
+   * @param property OWLDataProperty to create domain axioms for
+   * @param expressions set of domain OWLClassExpressions
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addDataPropertyDomains(
+      OWLDataProperty property, Set<OWLClassExpression> expressions, List<String> row, int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLClassExpression expr : expressions) {
+      axioms.add(dataFactory.getOWLDataPropertyDomainAxiom(property, expr, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLObjectProperty, a set of OWLDatatypes, the row containing the property details, and
+   * a column location, generate range axioms where the ranges are the datatypes. Maybe annotation
+   * the axioms.
+   *
+   * @param property OWLObjectProperty to create range axioms for
+   * @param datatypes set of range OWLDatatypes
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addDataPropertyRanges(
+      OWLDataProperty property, Set<OWLDatatype> datatypes, List<String> row, int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLDatatype datatype : datatypes) {
+      axioms.add(dataFactory.getOWLDataPropertyRangeAxiom(property, datatype, axiomAnnotations));
+    }
+  }
+
+  /* ANNOTATION PROPERTY AXIOMS */
+
+  /**
+   * Given an annotation property IRI and the row containing the property details, generate property
+   * axioms.
+   *
+   * @param iri annotation property IRI
+   * @param row list of template values for given annotation property
+   * @throws Exception on issue creating annotation property axioms from template
+   */
+  private void addAnnotationPropertyAxioms(IRI iri, List<String> row) throws Exception {
+    // Add the declaration
+    axioms.add(
+        dataFactory.getOWLDeclarationAxiom(
+            dataFactory.getOWLEntity(EntityType.ANNOTATION_PROPERTY, iri)));
+
+    String propertyType = getPropertyType(row);
+
+    if (!propertyType.equals("subproperty")) {
+      // Annotation properties can only have type "subproperty"
+      throw new RowParseException(
+          String.format(
+              annotationPropertyTypeError, iri, propertyType, rowNum, propertyTypeColumn, name));
+    }
+
+    // Annotation properties should not have characteristics
+    if (characteristicColumn != -1) {
+      String propertyCharacteristicString = row.get(characteristicColumn);
+      if (propertyCharacteristicString != null && !propertyCharacteristicString.trim().isEmpty()) {
+        throw new RowParseException(
+            String.format(
+                annotationPropertyCharacteristicError,
+                iri.getShortForm(),
+                rowNum,
+                characteristicColumn,
+                name));
+      }
+    }
+
+    // Create the property object
+    OWLAnnotationProperty property = dataFactory.getOWLAnnotationProperty(iri);
+
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      String value = null;
+      try {
+        value = row.get(column);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+
+      if (value == null || value.trim().isEmpty()) {
+        continue;
+      }
+
+      // Maybe get the split character
+      String split = null;
+      if (template.contains("SPLIT=")) {
+        split = template.substring(template.indexOf("SPLIT=") + 6).trim();
+      }
+
+      if (template.startsWith("A") || template.startsWith("LABEL")) {
+        // Handle annotations
+        Set<OWLAnnotation> annotations = getAnnotations(template, value, row, column);
+        for (OWLAnnotation annotation : annotations) {
+          axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(iri, annotation));
+        }
+      } else if (template.startsWith("SP")
+          || template.startsWith("P") && !template.startsWith("PROPERTY_TYPE")) {
+        // Handle property logic
+        Set<OWLAnnotationProperty> parents =
+            TemplateHelper.getAnnotationProperties(checker, value, split);
+        addSubAnnotationPropertyAxioms(property, parents, row, column);
+      } else if (template.startsWith("DOMAIN")) {
+        // Handle domains
+        Set<IRI> iris = TemplateHelper.getValueIRIs(checker, value, split);
+        addAnnotationPropertyDomains(property, iris, row, column);
+      } else if (template.startsWith("RANGE")) {
+        // Handle ranges
+        Set<IRI> iris = TemplateHelper.getValueIRIs(checker, value, split);
+        addAnnotationPropertyRanges(property, iris, row, column);
+      }
+    }
+  }
+
+  /**
+   * Given an OWLAnnotationProperty, a set of OWLAnnotationProperties, and the row containing the
+   * property details, generate subPropertyOf axioms for the property where the parents are the
+   * other properties. Maybe annotate the axioms.
+   *
+   * @param property OWLObjectProperty to create subPropertyOf axioms for
+   * @param parents set of parent OWLAnnotationProperties
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addSubAnnotationPropertyAxioms(
+      OWLAnnotationProperty property,
+      Set<OWLAnnotationProperty> parents,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the subproperty axiom
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (OWLAnnotationProperty parent : parents) {
+      axioms.add(
+          dataFactory.getOWLSubAnnotationPropertyOfAxiom(property, parent, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLAnnotationProperty, a set of IRIs, the row containing the property details, and a
+   * column location, generate domain axioms where the domains are the IRIs. Maybe annotation the
+   * axioms.
+   *
+   * @param property OWLObjectProperty to create domain axioms for
+   * @param iris set of domain IRIs
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addAnnotationPropertyDomains(
+      OWLAnnotationProperty property, Set<IRI> iris, List<String> row, int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (IRI iri : iris) {
+      axioms.add(dataFactory.getOWLAnnotationPropertyDomainAxiom(property, iri, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLAnnotationProperty, a set of IRIs, the row containing the property details, and a
+   * column location, generate range axioms where the ranges are the IRIs. Maybe annotation the
+   * axioms.
+   *
+   * @param property OWLAnnotationProperty to create range axioms for
+   * @param iris set of range IRIs
+   * @param row list of template values for given property
+   * @param column column number of logical template string
+   * @throws Exception on issue getting axiom annotations
+   */
+  private void addAnnotationPropertyRanges(
+      OWLAnnotationProperty property, Set<IRI> iris, List<String> row, int column)
+      throws Exception {
+    // Maybe get an annotation on the expression
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    for (IRI iri : iris) {
+      axioms.add(dataFactory.getOWLAnnotationPropertyRangeAxiom(property, iri, axiomAnnotations));
+    }
+  }
+
+  /* DATATYPE AXIOMS */
+
+  /**
+   * Given a datatype IRI and the row containing the datatype details, generate datatype axioms.
+   *
+   * @param iri datatype IRI
+   * @param row list of template values for given datatype
+   * @throws Exception on issue creating datatype annotations
+   */
+  private void addDatatypeAxioms(IRI iri, List<String> row) throws Exception {
+    // Add the declaration
+    axioms.add(
+        dataFactory.getOWLDeclarationAxiom(dataFactory.getOWLEntity(EntityType.DATATYPE, iri)));
+
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      String value = null;
+      try {
+        value = row.get(column);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+
+      if (value == null || value.trim().isEmpty()) {
+        continue;
+      }
+
+      // Handle annotations
+      if (template.startsWith("A")) {
+        // Add the annotations to the datatype
+        Set<OWLAnnotation> annotations = getAnnotations(template, value, row, column);
+        for (OWLAnnotation annotation : annotations) {
+          axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(iri, annotation));
+        }
+      }
+      // TODO - future support for data definitions with DT
+    }
+  }
+
+  /* INDIVIDUAL AXIOMS */
+
+  /**
+   * Given an individual IRI and the row containing the individual details, generate individual
+   * axioms.
+   *
+   * @param iri individual IRI
+   * @param row list of template values for given individual
+   * @throws Exception on issue creating individual axioms from template
+   */
+  private void addIndividualAxioms(IRI iri, List<String> row) throws Exception {
+    // Should not return null, as empty defaults to a class
+    String typeCol = row.get(typeColumn).trim();
+    // Use the 'type' to get the class assertion for the individual
+    // If it is owl:Individual or owl:NamedIndividual, it will not have a class assertion
+    // There may be more than one class assertion - right now only named classes are supported
+    List<String> types = new ArrayList<>();
+    if (typeSplit != null) {
+      for (String t : typeCol.split(Pattern.quote(typeSplit))) {
+        if (!t.trim().equals("")) {
+          types.add(t.trim());
+        }
+      }
+    } else {
+      types.add(typeCol.trim());
+    }
+
+    // The individualType is used to determine what kind of axioms are associated
+    // e.g. different individuals, same individuals...
+    // The default is just "named" individual (no special axioms)
+    String individualType = "named";
+    if (individualTypeColumn != -1) {
+      try {
+        individualType = row.get(individualTypeColumn);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+    }
+
+    OWLNamedIndividual individual = dataFactory.getOWLNamedIndividual(iri);
+    // Add declaration
+    axioms.add(dataFactory.getOWLDeclarationAxiom(dataFactory.getOWLNamedIndividual(iri)));
+
+    for (String type : types) {
+      // Trim for safety
+      type = type.trim();
+
+      // Try to resolve a CURIE
+      IRI typeIRI = ioHelper.createIRI(type);
+
+      // Set to IRI string or to type string
+      String typeOrIRI = type;
+      if (typeIRI != null) {
+        typeOrIRI = typeIRI.toString();
+      }
+
+      // Add a type if the type is not owl:Individual or owl:NamedIndividual
+      if (!typeOrIRI.equalsIgnoreCase("individual")
+          && !typeOrIRI.equalsIgnoreCase("named individual")
+          && !typeOrIRI.equalsIgnoreCase("http://www.w3.org/2002/07/owl#NamedIndividual")
+          && !typeOrIRI.equalsIgnoreCase("http://www.w3.org/2002/07/owl#Individual")) {
+        OWLClass typeCls = checker.getOWLClass(type);
+        if (typeCls != null) {
+          axioms.add(dataFactory.getOWLClassAssertionAxiom(typeCls, individual));
+        } else {
+          // If the class is null, assume it is a class expression
+          OWLClassExpression typeExpr =
+              TemplateHelper.tryParse(name, parser, type, rowNum, typeColumn);
+          axioms.add(dataFactory.getOWLClassAssertionAxiom(typeExpr, individual));
+        }
+      }
+    }
+
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      String split = null;
+      if (template.contains("SPLIT=")) {
+        split = template.substring(template.indexOf("SPLIT=") + 6).trim();
+        template = template.substring(0, template.indexOf("SPLIT=")).trim();
+      }
+
+      String value = null;
+      try {
+        value = row.get(column);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+
+      if (value == null || value.trim().isEmpty()) {
+        continue;
+      }
+
+      // Handle annotations
+      if (template.startsWith("A") || template.startsWith("LABEL")) {
+        // Add the annotations to the individual
+        Set<OWLAnnotation> annotations = getAnnotations(template, value, row, column);
+        for (OWLAnnotation annotation : annotations) {
+          axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(iri, annotation));
+        }
+      } else if (template.startsWith("SI")) {
+        // Same individuals axioms
+        Set<OWLIndividual> sameIndividuals = TemplateHelper.getIndividuals(checker, value, split);
+        if (!sameIndividuals.isEmpty()) {
+          addSameIndividualsAxioms(individual, sameIndividuals, row, column);
+        }
+      } else if (template.startsWith("DI")) {
+        // Different individuals axioms
+        Set<OWLIndividual> differentIndividuals =
+            TemplateHelper.getIndividuals(checker, value, split);
+        if (!differentIndividuals.isEmpty()) {
+          addDifferentIndividualsAxioms(individual, differentIndividuals, row, column);
+        }
+      } else if (template.startsWith("I") && !template.startsWith("INDIVIDUAL_TYPE")) {
+        // Use individual type to determine how to handle expressions
+        switch (individualType) {
+          case "named":
+            if (template.startsWith("I ")) {
+              String propStr = template.substring(2).replace("'", "");
+              OWLObjectProperty objectProperty = checker.getOWLObjectProperty(propStr);
+              if (objectProperty != null) {
+                Set<OWLIndividual> otherIndividuals =
+                    TemplateHelper.getIndividuals(checker, value, split);
+                addObjectPropertyAssertionAxioms(
+                    individual, otherIndividuals, objectProperty, row, column);
+                break;
+              }
+              OWLDataProperty dataProperty = checker.getOWLDataProperty(propStr);
+              if (dataProperty != null) {
+                Set<OWLLiteral> literals =
+                    TemplateHelper.getLiterals(name, checker, value, split, rowNum, column);
+                addDataPropertyAssertionAxioms(individual, literals, dataProperty, row, column);
+                break;
+              }
+            }
+            break;
+          case "same":
+            Set<OWLIndividual> sameIndividuals =
+                TemplateHelper.getIndividuals(checker, value, split);
+            if (!sameIndividuals.isEmpty()) {
+              addSameIndividualsAxioms(individual, sameIndividuals, row, column);
+            }
+            break;
+          case "different":
+            Set<OWLIndividual> differentIndividuals =
+                TemplateHelper.getIndividuals(checker, value, split);
+            if (!differentIndividuals.isEmpty()) {
+              addDifferentIndividualsAxioms(individual, differentIndividuals, row, column);
+            }
+            break;
+          default:
+            throw new RowParseException(
+                String.format(
+                    individualTypeError,
+                    iri.getShortForm(),
+                    individualType,
+                    rowNum,
+                    column + 1,
+                    name));
+        }
+      }
+    }
+  }
+
+  /**
+   * Given an OWLIndividual, a set of OWLIndividuals, an object property expression, the row as list
+   * of strings, and the column number, add each individual as the object of the object property
+   * expression for that individual.
+   *
+   * @param individual OWLIndividual to add object property assertion axioms to
+   * @param otherIndividuals set of other OWLIndividuals representing the objects of the axioms
+   * @param expression OWLObjectPropertyExpression to use as property of the axioms
+   * @param row list of strings
+   * @param column column number
+   * @throws Exception on problem handling axiom annotations
+   */
+  private void addObjectPropertyAssertionAxioms(
+      OWLNamedIndividual individual,
+      Set<OWLIndividual> otherIndividuals,
+      OWLObjectPropertyExpression expression,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the subproperty axiom
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    for (OWLIndividual other : otherIndividuals) {
+      axioms.add(
+          dataFactory.getOWLObjectPropertyAssertionAxiom(
+              expression, individual, other, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLIndividual, a set of OWLLiterals, a data property expression, the row as list of
+   * strings, and the column number, add each literal as the object of the data property expression
+   * for that individual.
+   *
+   * @param individual OWLIndividual to add data property assertion axioms to
+   * @param literals set of OWLLiterals representing the objects of the axioms
+   * @param expression OWLDataPropertyExpression to use as property of the axioms
+   * @param row list of strings
+   * @param column column number
+   * @throws Exception on problem handling axiom annotations
+   */
+  private void addDataPropertyAssertionAxioms(
+      OWLNamedIndividual individual,
+      Set<OWLLiteral> literals,
+      OWLDataPropertyExpression expression,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the subproperty axiom
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    for (OWLLiteral lit : literals) {
+      axioms.add(
+          dataFactory.getOWLDataPropertyAssertionAxiom(
+              expression, individual, lit, axiomAnnotations));
+    }
+  }
+
+  /**
+   * Given an OWLIndividual, a set of same individuals, a row as list of strings, and a column
+   * number, add the same individual axioms.
+   *
+   * @param individual OWLIndiviudal to add axioms to
+   * @param sameIndividuals set of same individuals
+   * @param row list of strings
+   * @param column column number
+   * @throws Exception on problem handling axiom annotations
+   */
+  private void addSameIndividualsAxioms(
+      OWLNamedIndividual individual,
+      Set<OWLIndividual> sameIndividuals,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the subproperty axiom
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    sameIndividuals.add(individual);
+    axioms.add(dataFactory.getOWLSameIndividualAxiom(sameIndividuals, axiomAnnotations));
+  }
+
+  /**
+   * Given an OWLIndividual, a set of different individuals, a row as list of strings, and a column
+   * number, add the different individual axioms.
+   *
+   * @param individual OWLIndiviudal to add axioms to
+   * @param differentIndividuals set of different individuals
+   * @param row list of strings
+   * @param column column number
+   * @throws Exception on problem handling axiom annotations
+   */
+  private void addDifferentIndividualsAxioms(
+      OWLNamedIndividual individual,
+      Set<OWLIndividual> differentIndividuals,
+      List<String> row,
+      int column)
+      throws Exception {
+    // Maybe get an annotation on the subproperty axiom
+    Set<OWLAnnotation> axiomAnnotations = maybeGetAxiomAnnotations(row, column);
+
+    // Generate axioms
+    differentIndividuals.add(individual);
+    axioms.add(dataFactory.getOWLDifferentIndividualsAxiom(differentIndividuals, axiomAnnotations));
+  }
+
+  /* ANNOTATION HELPERS */
+
+  /**
+   * Given a template string, a value string, a row as a list of strings, and the column number,
+   * return a set of one or more OWLAnnotations.
+   *
+   * @param template template string
+   * @param value value of annotation(s)
+   * @param row list of strings
+   * @param column column number
+   * @return Set of OWLAnnotations
+   * @throws Exception on issue getting OWLAnnotations
+   */
+  private Set<OWLAnnotation> getAnnotations(
+      String template, String value, List<String> row, int column) throws Exception {
+    if (value == null || value.trim().equals("")) {
+      return new HashSet<>();
+    }
+
+    Set<OWLAnnotation> annotations =
+        TemplateHelper.getAnnotations(name, checker, template, value, rowNum, column);
+
+    // Maybe get an annotation on the annotation
+    String nextTemplate;
+    try {
+      nextTemplate = templates.get(column + 1);
+    } catch (IndexOutOfBoundsException e) {
+      nextTemplate = null;
+    }
+
+    // Handle axiom annotations
+    if (nextTemplate != null && !nextTemplate.trim().isEmpty() && nextTemplate.startsWith(">A")) {
+      nextTemplate = nextTemplate.substring(1);
+      String nextValue;
+      try {
+        nextValue = row.get(column + 1);
+      } catch (IndexOutOfBoundsException e) {
+        nextValue = null;
+      }
+      if (nextValue != null && !nextValue.trim().equals("")) {
+        Set<OWLAnnotation> nextAnnotations =
+            TemplateHelper.getAnnotations(name, checker, nextTemplate, nextValue, rowNum, column);
+        Set<OWLAnnotation> fixedAnnotations = new HashSet<>();
+        for (OWLAnnotation annotation : annotations) {
+          fixedAnnotations.add(annotation.getAnnotatedAnnotation(nextAnnotations));
+        }
+        // Replace the annotation set with the annotated annotations
+        annotations = fixedAnnotations;
+      }
+    }
+
+    return annotations;
+  }
+
+  /**
+   * Given a row as a list of strings, the template string, and the number of the next column, maybe
+   * get axiom annotations on existing axiom annotations.
+   *
+   * @param row list of strings
+   * @param template template string for the column
+   * @param nextColumn next column number
+   * @return set of OWLAnnotations, or an empty set
+   * @throws Exception on issue getting the OWLAnnotations
+   */
+  private Set<OWLAnnotation> getAxiomAnnotations(List<String> row, String template, int nextColumn)
+      throws Exception {
+    Set<OWLAnnotation> annotations = new HashSet<>();
+    if (template.startsWith(">C")) {
+      logger.warn(
+          String.format(
+              ">A* should be used for all axiom annotations\n(Template '%s' in column %d)",
+              template, nextColumn));
+    }
+    template = template.substring(1);
+    String nextValue;
+    try {
+      nextValue = row.get(nextColumn);
+    } catch (IndexOutOfBoundsException e) {
+      nextValue = null;
+    }
+    if (nextValue == null || nextValue.trim().equals("")) {
+      return annotations;
+    }
+    annotations.addAll(
+        TemplateHelper.getAnnotations(name, checker, template, nextValue, rowNum, nextColumn));
+    return annotations;
+  }
+
+  /**
+   * Given a row as a list of strings and a column number, determine if the next column contains a
+   * one or more axiom annotations. If so, return the axiom annotation or annotations as a set of
+   * OWLAnnotations.
+   *
+   * @param row list of strings
+   * @param column column number
+   * @return set of OWLAnnotations, maybe empty
+   * @throws Exception on issue getting the OWLAnnotations
+   */
+  private Set<OWLAnnotation> maybeGetAxiomAnnotations(List<String> row, int column)
+      throws Exception {
+    // Look at the template string of the next column
+    String nextTemplate;
+    try {
+      nextTemplate = templates.get(column + 1);
+    } catch (IndexOutOfBoundsException e) {
+      nextTemplate = null;
+    }
+
+    Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
+    // If the next template string is not null, not empty, and it starts with >
+    // Get the axiom annotations from the row
+    if (nextTemplate != null && !nextTemplate.trim().isEmpty() && (nextTemplate.startsWith(">"))) {
+      axiomAnnotations = getAxiomAnnotations(row, nextTemplate, column + 1);
+    }
+    return axiomAnnotations;
+  }
+
+  /**
+   * Given a property string (label or CURIE) and the column of that template string, determine if
+   * this is RDFS label and if so, set the label column.
+   *
+   * @param property property string
+   * @param column int column number
+   */
+  private void maybeSetLabelColumn(String property, int column) {
+    OWLAnnotationProperty ap = checker.getOWLAnnotationProperty(property, true);
+    if (ap != null) {
+      if (ap.getIRI().toString().equals(dataFactory.getRDFSLabel().getIRI().toString())) {
+        labelColumn = column;
+      }
+    }
+  }
+
+  /* OTHER HELPERS */
+
+  /**
+   * Given a string ID and a string label, with at least one of those being non-null, return an IRI
+   * for the entity.
+   *
+   * @param id String ID of entity, maybe null
+   * @param label String label of entity, maybe null
+   * @return IRI of entity
+   * @throws Exception if both id and label are null
+   */
+  private IRI getIRI(String id, String label) throws Exception {
+    if (id == null && label == null) {
+      // This cannot be hit by CLI users
+      throw new Exception("You must specify either an ID or a label");
+    }
+    if (id != null) {
+      return ioHelper.createIRI(id);
+    }
+    return checker.getIRI(label, true);
+  }
+
+  /**
+   * Given a row, get the property type if it exists. If not, return default of "subproperty".
+   *
+   * @param row list of strings
+   * @return property type
+   */
+  private String getPropertyType(List<String> row) {
+    String propertyType = null;
+    if (propertyTypeColumn != -1) {
+      try {
+        propertyType = row.get(propertyTypeColumn);
+      } catch (IndexOutOfBoundsException e) {
+        // do nothing
+      }
+    }
+    if (propertyType == null || propertyType.trim().isEmpty()) {
+      return "subproperty";
+    } else {
+      return propertyType.trim().toLowerCase();
+    }
+  }
+
+  /**
+   * Given a row, get the list of characteristics if they exist. If not, return an empty list.
+   *
+   * @param row list of strings
+   * @return characteristics
+   */
+  private List<String> getCharacteristics(List<String> row) {
+    if (characteristicColumn != -1) {
+      String characteristicString = row.get(characteristicColumn);
+      if (characteristicSplit != null && characteristicString.contains(characteristicSplit)) {
+        return Arrays.asList(characteristicString.split(Pattern.quote(characteristicSplit)));
+      } else {
+        return Collections.singletonList(characteristicString.trim());
+      }
+    }
+    return new ArrayList<>();
+  }
+}
diff --git a/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java b/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
index 09e56524..fadbc369 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
@@ -20,26 +20,34 @@ import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.apache.commons.io.FilenameUtils;
-import org.semanticweb.owlapi.model.IRI;
-import org.semanticweb.owlapi.model.OWLAnnotation;
-import org.semanticweb.owlapi.model.OWLAnnotationAssertionAxiom;
-import org.semanticweb.owlapi.model.OWLAnnotationProperty;
-import org.semanticweb.owlapi.model.OWLAnnotationValue;
-import org.semanticweb.owlapi.model.OWLAxiom;
-import org.semanticweb.owlapi.model.OWLClass;
-import org.semanticweb.owlapi.model.OWLClassExpression;
-import org.semanticweb.owlapi.model.OWLDataFactory;
-import org.semanticweb.owlapi.model.OWLDatatype;
-import org.semanticweb.owlapi.model.OWLEntity;
-import org.semanticweb.owlapi.model.OWLObjectIntersectionOf;
+import org.obolibrary.robot.exceptions.ColumnException;
+import org.obolibrary.robot.exceptions.RowParseException;
+import org.semanticweb.owlapi.OWLAPIConfigProvider;
+import org.semanticweb.owlapi.apibinding.OWLManager;
+import org.semanticweb.owlapi.io.OWLParserException;
+import org.semanticweb.owlapi.manchestersyntax.parser.ManchesterOWLSyntaxClassExpressionParser;
+import org.semanticweb.owlapi.manchestersyntax.parser.ManchesterOWLSyntaxParserImpl;
+import org.semanticweb.owlapi.model.*;
+import org.semanticweb.owlapi.util.mansyntax.ManchesterOWLSyntaxParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import uk.ac.manchester.cs.owl.owlapi.OWLDataFactoryImpl;
 
-/** Convenience methods for working with templates. */
+/**
+ * Convenience methods for working with templates.
+ *
+ * @author <a href="mailto:rctauber@gmail.com">Becky Tauber</a>
+ */
 public class TemplateHelper {
 
+  /** Logger */
+  private static final Logger logger = LoggerFactory.getLogger(TemplateHelper.class);
+
   /** Shared DataFactory. */
   private static OWLDataFactory dataFactory = new OWLDataFactoryImpl();
 
+  /* Error messages */
+
   /** Namespace for error messages. */
   private static final String NS = "template#";
 
@@ -49,12 +57,18 @@ public class TemplateHelper {
   private static final String annotationPropertyError =
       NS + "ANNOTATION PROPERTY ERROR could not handle annotation property: %s";
 
+  /** Error message when a given class expression is not able to be parsed. */
+  protected static final String manchesterParseError =
+      NS
+          + "MANCHESTER PARSE ERROR the expression '%s' at row %d, column %d in table \"%s\" cannot be parsed: %s";
+
   /** Error message when the CLASS_TYPE is not subclass or equivalent. */
   private static final String classTypeError =
       NS + "CLASS TYPE ERROR '%s' is not a valid CLASS_TYPE";
 
   /** Error message when datatype cannot be resolved. Expects: datatype name. */
-  private static final String datatypeError = NS + "DATATYPE ERROR could not find datatype: %s";
+  private static final String datatypeError =
+      NS + "DATATYPE ERROR could not find datatype for '%s' at row %d, column %d in table \"%s\".";
 
   /** Error message when template file type is not CSV, TSV, or TAB. Expects: file name. */
   private static final String fileTypeError = NS + "FILE TYPE ERROR unrecognized file type for: %s";
@@ -63,14 +77,16 @@ public class TemplateHelper {
   private static final String idError = NS + "ID ERROR an \"ID\" column is required in table %s";
 
   /** Error message when the IRI in an IRI annotation cannot be resolved. Expects: value. */
-  private static final String iriError = NS + "IRI ERROR could not create IRI annotation: %s";
+  private static final String iriError =
+      NS + "IRI ERROR could not create IRI annotation at row %d, column %d in table \"%s\": %s";
 
   /**
    * Error message when a language annotation string does not include "@{lang}". Expects: template
    * string
    */
   private static final String languageFormatError =
-      NS + "LANGUAGE FORMAT ERROR invalid language annotation template string: %s";
+      NS
+          + "LANGUAGE FORMAT ERROR invalid language annotation template string at column %d in table \"%s\": %s";
 
   /** Error message when the template file does not exist. Expects: file name. */
   private static final String templateFileError =
@@ -81,33 +97,60 @@ public class TemplateHelper {
    * string
    */
   private static final String typedFormatError =
-      NS + "TYPED FORMAT ERROR invalid typed annotation string: %s";
+      NS + "TYPED FORMAT ERROR invalid typed annotation string at column %d in table \"%s\": %s";
+
+  /* OWL entity type IRIs */
+
+  private static final String OWL = "http://www.w3.org/2002/07/owl#";
+
+  private static final String OWL_ANNOTATION_PROPERTY = OWL + "AnnotationProperty";
+
+  private static final String OWL_CLASS = OWL + "Class";
+
+  private static final String OWL_DATA_PROPERTY = OWL + "DatatypeProperty";
 
-  /** OWL Namespace. */
-  private static String owl = "http://www.w3.org/2002/07/owl#";
+  private static final String OWL_DATATYPE = OWL + "Datatype";
+
+  private static final String OWL_OBJECT_PROPERTY = OWL + "ObjectProperty";
 
   /**
-   * Given a set of rows, the row number, and the column number, get the content in the column for
-   * the row. If there are any issues, return an empty string. If the cell is empty, return null.
+   * Given a QuotedEntityChecker, a string value, and a character to split the value string on (or
+   * null), return the value or values as a set of OWLAnnotationProperties.
    *
-   * @param rows list of rows (lists of strings)
-   * @param row row number to get ID of
-   * @param column column number
-   * @return content, null, or empty string.
+   * @param checker QuotedEntityChecker to get properties
+   * @param value value or values to parse to properties
+   * @param split character to split value on or null
+   * @return set of OWLAnnotationProperties
+   * @throws RowParseException if property cannot be found or created
    */
-  public static String getCellContent(List<List<String>> rows, int row, Integer column) {
-    String id = null;
-    if (column != null && column != -1) {
-      try {
-        id = rows.get(row).get(column);
-      } catch (IndexOutOfBoundsException e) {
-        return "";
-      }
-      if (id == null || id.trim().isEmpty()) {
-        return "";
-      }
+  public static Set<OWLAnnotationProperty> getAnnotationProperties(
+      QuotedEntityChecker checker, String value, String split) throws Exception {
+    List<String> allValues = getAllValues(value, split);
+
+    Set<OWLAnnotationProperty> properties = new HashSet<>();
+    for (String v : allValues) {
+      String content = QuotedEntityChecker.wrap(v);
+      properties.add(getAnnotationProperty(checker, content));
     }
-    return id;
+
+    return properties;
+  }
+
+  /**
+   * Find an annotation property with the given name or create one.
+   *
+   * @param checker used to search by rdfs:label (for example)
+   * @param name the name to search for
+   * @return an annotation property
+   * @throws RowParseException if the name cannot be resolved
+   */
+  public static OWLAnnotationProperty getAnnotationProperty(
+      QuotedEntityChecker checker, String name) throws Exception {
+    OWLAnnotationProperty property = checker.getOWLAnnotationProperty(name);
+    if (property != null) {
+      return property;
+    }
+    throw new RowParseException(String.format(annotationPropertyError, name));
   }
 
   /**
@@ -119,6 +162,7 @@ public class TemplateHelper {
    * @return OWLAnnotation, or null if template string is not supported
    * @throws Exception if annotation cannot be created
    */
+  @Deprecated
   public static OWLAnnotation getAnnotation(
       QuotedEntityChecker checker, String template, String value) throws Exception {
     if (template.startsWith("A ")) {
@@ -127,13 +171,16 @@ public class TemplateHelper {
       if (template.contains("^^")) {
         return getTypedAnnotation(checker, template, value);
       } else {
-        throw new Exception(String.format(typedFormatError, template));
+        throw new Exception(
+            String.format("TYPED FORMAT ERROR invalid typed annotation string: %s", template));
       }
     } else if (template.startsWith("AL ")) {
       if (template.contains("@")) {
         return getLanguageAnnotation(checker, template, value);
       } else {
-        throw new Exception(String.format(languageFormatError, template));
+        throw new Exception(
+            String.format(
+                "LANGUAGE FORMAT ERROR invalid language annotation template string: %s", template));
       }
     } else if (template.startsWith("AI ")) {
       return getIRIAnnotation(checker, template, value);
@@ -142,6 +189,55 @@ public class TemplateHelper {
     }
   }
 
+  /**
+   * Create an OWLAnnotation based on the template string and cell value. Replaced by
+   * getAnnotation(QuotedEntityChecker checker, String template, String value).
+   *
+   * @param tableName name of table
+   * @param checker used to resolve the annotation property
+   * @param ioHelper IOHelper used to create IRIs from values
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return OWLAnnotation, or null if template string is not supported
+   * @throws Exception if annotation property cannot be found
+   */
+  @Deprecated
+  public static OWLAnnotation getAnnotation(
+      String tableName,
+      QuotedEntityChecker checker,
+      IOHelper ioHelper,
+      String template,
+      String value)
+      throws Exception {
+    if (template.startsWith("A ")) {
+      return getStringAnnotation(checker, template, value);
+    } else if (template.startsWith("AT ")) {
+      if (template.contains("^^")) {
+        return getTypedAnnotation(checker, template, value);
+      } else {
+        throw new Exception(
+            String.format("TYPED FORMAT ERROR invalid typed annotation string: %s", template));
+      }
+    } else if (template.startsWith("AL ")) {
+      if (template.contains("@")) {
+        return getLanguageAnnotation(checker, template, value);
+      } else {
+        throw new Exception(
+            String.format(
+                "LANGUAGE FORMAT ERROR invalid language annotation template string: %s", template));
+      }
+    } else if (template.startsWith("AI ")) {
+      IRI iri = ioHelper.createIRI(value);
+      if (iri != null) {
+        return getIRIAnnotation(checker, template, iri);
+      } else {
+        throw new RowParseException(String.format(iriError, 0, 0, tableName, value));
+      }
+    } else {
+      return null;
+    }
+  }
+
   /**
    * Create an OWLAnnotation based on the template string and cell value. Replaced by
    * getAnnotation(QuotedEntityChecker checker, String template, String value).
@@ -163,20 +259,20 @@ public class TemplateHelper {
       if (template.contains("^^")) {
         return getTypedAnnotation(checker, template, value);
       } else {
-        throw new Exception(String.format(typedFormatError, template));
+        throw new Exception(String.format(typedFormatError, 0, "", template));
       }
     } else if (template.startsWith("AL ")) {
       if (template.contains("@")) {
         return getLanguageAnnotation(checker, template, value);
       } else {
-        throw new Exception(String.format(languageFormatError, template));
+        throw new Exception(String.format(languageFormatError, 0, "", template));
       }
     } else if (template.startsWith("AI ")) {
       IRI iri = ioHelper.createIRI(value);
       if (iri != null) {
         return getIRIAnnotation(checker, template, iri);
       } else {
-        throw new Exception(String.format(iriError, value));
+        throw new Exception(String.format(iriError, 0, 0, "", value));
       }
     } else {
       return null;
@@ -184,187 +280,270 @@ public class TemplateHelper {
   }
 
   /**
-   * Get a set of annotation axioms for an OWLEntity. Supports axiom annotations and axiom
-   * annotation annotations.
+   * Create an OWLAnnotation based on the template string and cell value. Replaced by
+   * getAnnotation(QuotedEntityChecker checker, String template, String value).
    *
-   * @param entity OWLEntity to annotation
-   * @param annotations Set of OWLAnnotations
-   * @param nested Map with top-level OWLAnnotation as key and another map (axiom OWLAnnotation, set
-   *     of axiom annotation OWLAnnotations) as value
-   * @return Set of OWLAnnotationAssertionAxioms
+   * @param tableName name of table
+   * @param checker used to resolve the annotation property and IRIs
+   * @param template the template string
+   * @param value the value for the annotation
+   * @param rowNum the row number for logging
+   * @param column the column number for logging
+   * @return OWLAnnotation, or null if template string is not supported
+   * @throws RowParseException if a row value cannot be parsed
+   * @throws ColumnException if a header annotation template cannot be parsed
    */
-  public static Set<OWLAnnotationAssertionAxiom> getAnnotationAxioms(
-      OWLEntity entity,
-      Set<OWLAnnotation> annotations,
-      Map<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> nested) {
-    Set<OWLAnnotationAssertionAxiom> axioms = new HashSet<>();
-    // Create basic annotations
-    for (OWLAnnotation annotation : annotations) {
-      axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(entity.getIRI(), annotation));
+  public static Set<OWLAnnotation> getAnnotations(
+      String tableName,
+      QuotedEntityChecker checker,
+      String template,
+      String value,
+      int rowNum,
+      int column)
+      throws Exception {
+    String split = getSplit(template);
+    template = getTemplate(template);
+
+    // Trim the > if it hasn't been trimmed yet
+    if (template.startsWith(">")) {
+      template = template.substring(1);
     }
-    // Create annotations with axiom annotations
-    for (Entry<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> annotationPlusAxioms :
-        nested.entrySet()) {
-      OWLAnnotation annotation = annotationPlusAxioms.getKey();
-      Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
-      // For each annotation with its axiom annotations ...
-      for (Entry<OWLAnnotation, Set<OWLAnnotation>> nestedSet :
-          annotationPlusAxioms.getValue().entrySet()) {
-        OWLAnnotation axiomAnnotation = nestedSet.getKey();
-        // Check if there are annotations on the axiom annotations, and add those
-        Set<OWLAnnotation> axiomAnnotationAnnotations = nestedSet.getValue();
-        if (axiomAnnotationAnnotations.isEmpty()) {
-          axiomAnnotations.add(axiomAnnotation);
-        } else {
-          OWLAnnotationProperty property = axiomAnnotation.getProperty();
-          OWLAnnotationValue value = axiomAnnotation.getValue();
-          axiomAnnotations.add(
-              dataFactory.getOWLAnnotation(property, value, axiomAnnotationAnnotations));
+
+    if (template.startsWith("A ") || template.startsWith("C ")) {
+      return getStringAnnotations(checker, template, split, value);
+    } else if (template.startsWith("AT ") || template.startsWith("CT ")) {
+      if (template.contains("^^")) {
+        return getTypedAnnotations(tableName, checker, template, split, value, rowNum, column);
+      } else {
+        throw new ColumnException(String.format(typedFormatError, column, tableName, template));
+      }
+    } else if (template.startsWith("AL ") || template.startsWith("CL ")) {
+      if (template.contains("@")) {
+        return getLanguageAnnotations(checker, template, split, value);
+      } else {
+        throw new ColumnException(String.format(languageFormatError, column, tableName, template));
+      }
+    } else if (template.startsWith("AI ") || template.startsWith("CI ")) {
+      Set<OWLAnnotation> annotations = new HashSet<>();
+      if (split != null) {
+        String[] values = value.split(Pattern.quote(split));
+        for (String v : values) {
+          annotations.add(maybeGetIRIAnnotation(tableName, checker, template, v, rowNum, column));
         }
+      } else {
+        annotations.add(maybeGetIRIAnnotation(tableName, checker, template, value, rowNum, column));
       }
-      axioms.add(
-          dataFactory.getOWLAnnotationAssertionAxiom(
-              entity.getIRI(), annotation, axiomAnnotations));
+      return annotations;
+    } else if (template.equals("LABEL")) {
+      // Handle special LABEL case
+      return getStringAnnotations(checker, template, split, value);
+    } else {
+      return new HashSet<>();
     }
-    return axioms;
   }
 
   /**
-   * Get a set of OWLAxioms (subclass or equivalent) for an OWLClass. Supports axiom annotations.
+   * Given a Manchester Syntax parser, a template string, and a template value, insert the value
+   * into the template string and parse to a set of class expressions. If the template string
+   * contains 'SPLIT=', the value will be split on the provided character. Otherwise, the set will
+   * only have one entry.
    *
-   * @param cls OWLClass to add axioms to
-   * @param classType subclass or equivalent
-   * @param classExpressions Set of OWLClassExpressions
-   * @param annotatedExpressions Map of annotated OWLClassExpressions and the Set of OWLAnnotations
-   * @return Set of OWLAxioms, or null if classType is not subclass or equivalent
-   * @throws Exception if classType is not subclass or equivalent
+   * @param tableName name of table
+   * @param parser ManchesterOWLSyntaxClassExpressionParser to parse expression
+   * @param template template string
+   * @param value value to replace '%' in template string
+   * @param rowNum the line number
+   * @param col the column number
+   * @return set of OWLClassExpressions
+   * @throws RowParseException if row is malformed
    */
-  public static Set<OWLAxiom> getLogicalAxioms(
-      OWLClass cls,
-      String classType,
-      Set<OWLClassExpression> classExpressions,
-      Map<OWLClassExpression, Set<OWLAnnotation>> annotatedExpressions)
-      throws Exception {
-    Set<OWLAxiom> axioms = new HashSet<>();
-    switch (classType) {
-      case "subclass":
-        for (OWLClassExpression expression : classExpressions) {
-          axioms.add(dataFactory.getOWLSubClassOfAxiom(cls, expression));
-        }
-        for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
-            annotatedExpressions.entrySet()) {
-          axioms.add(
-              dataFactory.getOWLSubClassOfAxiom(cls, annotatedEx.getKey(), annotatedEx.getValue()));
-        }
-        return axioms;
-      case "equivalent":
-        // Since it's an intersection, all annotations will be added to the same axiom
-        Set<OWLAnnotation> annotations = new HashSet<>();
-        for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
-            annotatedExpressions.entrySet()) {
-          classExpressions.add(annotatedEx.getKey());
-          annotations.addAll(annotatedEx.getValue());
+  public static Set<OWLClassExpression> getClassExpressions(
+      String tableName,
+      ManchesterOWLSyntaxClassExpressionParser parser,
+      String template,
+      String value,
+      int rowNum,
+      int col)
+      throws RowParseException {
+    String split = getSplit(template);
+    template = getTemplate(template);
+
+    Set<OWLClassExpression> expressions = new HashSet<>();
+    if (template.startsWith("CI")) {
+      // CI indicates its just an IRI
+      if (split != null) {
+        String[] values = value.split(Pattern.quote(split));
+        for (String v : values) {
+          String content = QuotedEntityChecker.wrap(v);
+          expressions.add(tryParse(tableName, parser, content, rowNum, col));
         }
-        OWLObjectIntersectionOf intersection =
-            dataFactory.getOWLObjectIntersectionOf(classExpressions);
-        OWLAxiom axiom;
-        if (!annotations.isEmpty()) {
-          axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection, annotations);
-        } else {
-          axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection);
+      } else {
+        String content = QuotedEntityChecker.wrap(value);
+        expressions.add(tryParse(tableName, parser, content, rowNum, col));
+      }
+    } else {
+      if (split != null) {
+        String[] values = value.split(Pattern.quote(split));
+        for (String v : values) {
+          expressions.add(getClassExpression(tableName, template, v, parser, rowNum, col));
         }
-        return Sets.newHashSet(axiom);
-      default:
-        throw new Exception(String.format(classTypeError, classType));
+      } else {
+        expressions.add(getClassExpression(tableName, template, value, parser, rowNum, col));
+      }
     }
+    return expressions;
   }
 
   /**
-   * Find an annotation property with the given name or create one.
+   * Given a QuotedEntityChecker, a template string, and a template value, return a set of data
+   * property expressions based on the value or values (if SPLIT is included in the template
+   * string). Note that a data property expression can ONLY be another data property, but this
+   * allows support for future data property expressions.
    *
-   * @param checker used to search by rdfs:label (for example)
-   * @param name the name to search for
-   * @return an annotation property
-   * @throws Exception if the name cannot be resolved
+   * @param tableName name of table
+   * @param checker QuotedEntityChecker to resolve entities
+   * @param template template string
+   * @param value template value or values
+   * @param rowNum the row number for logging
+   * @param column the column number for logging
+   * @return set of OWLDataPropertyExpressions
+   * @throws RowParseException if row is malformed
    */
-  public static OWLAnnotationProperty getAnnotationProperty(
-      QuotedEntityChecker checker, String name) throws Exception {
-    OWLAnnotationProperty property = checker.getOWLAnnotationProperty(name, true);
-    if (property != null) {
-      return property;
+  public static Set<OWLDataPropertyExpression> getDataPropertyExpressions(
+      String tableName,
+      QuotedEntityChecker checker,
+      String template,
+      String value,
+      int rowNum,
+      int column)
+      throws RowParseException {
+    String split = getSplit(template);
+    template = getTemplate(template);
+
+    // Create a parser
+    ManchesterOWLSyntaxParser parser =
+        new ManchesterOWLSyntaxParserImpl(
+            OWLOntologyLoaderConfiguration::new, OWLManager.getOWLDataFactory());
+    parser.setOWLEntityChecker(checker);
+
+    // Maybe split values
+    List<String> allValues = getAllValues(value, split);
+
+    Set<OWLDataPropertyExpression> expressions = new HashSet<>();
+    if (template.startsWith("PI")) {
+      // PI indicates its just an IRI
+      for (String v : allValues) {
+        String content = QuotedEntityChecker.wrap(v);
+        OWLDataProperty property = checker.getOWLDataProperty(content);
+        expressions.add(property);
+      }
+    } else {
+      for (String v : allValues) {
+        String content = QuotedEntityChecker.wrap(v);
+        // Get the template without identifier by breaking on the first space
+        String sub = template.substring(template.indexOf(" ")).trim().replaceAll("%", content);
+        parser.setStringToParse(sub);
+        logger.info("Parsing expression '%s'", sub);
+        try {
+          expressions.addAll(parser.parseDataPropertyList());
+        } catch (OWLParserException e) {
+          String cause = getManchesterErrorCause(e);
+          throw new RowParseException(
+              String.format(manchesterParseError, sub, rowNum, column + 1, tableName, cause));
+        }
+      }
     }
-    throw new Exception(String.format(annotationPropertyError, name));
+
+    return expressions;
   }
 
   /**
    * Find a datatype with the given name or create one.
    *
+   * @param tableName name of table
    * @param checker used to search by rdfs:label (for example)
    * @param name the name to search for
+   * @param rowNum the row number
+   * @param column the column number
    * @return a datatype
-   * @throws Exception if the name cannot be resolved
+   * @throws RowParseException if the name cannot be resolved
    */
-  public static OWLDatatype getDatatype(QuotedEntityChecker checker, String name) throws Exception {
-    OWLDatatype datatype = checker.getOWLDatatype(name, true);
+  public static OWLDatatype getDatatype(
+      String tableName, QuotedEntityChecker checker, String name, int rowNum, int column)
+      throws RowParseException {
+    OWLDatatype datatype = checker.getOWLDatatype(name);
     if (datatype != null) {
       return datatype;
     }
-    throw new Exception(String.format(datatypeError, name));
+    throw new RowParseException(String.format(datatypeError, name, rowNum, column + 1, tableName));
   }
 
   /**
-   * Return a string annotation for the given template string and value.
+   * Find a datatype with the given name or create one.
    *
-   * @param checker used to resolve the annotation property
-   * @param template the template string
-   * @param value the value for the annotation
-   * @return a new annotation with property and string literal value
-   * @throws Exception if the annotation property cannot be found
+   * @param checker used to search by rdfs:label (for example)
+   * @param name the name to search for
+   * @return a datatype or null
    */
-  public static OWLAnnotation getStringAnnotation(
-      QuotedEntityChecker checker, String template, String value) throws Exception {
-    String name = template.substring(1).trim();
-    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
-    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value));
+  public static OWLDatatype getDatatype(QuotedEntityChecker checker, String name) {
+    return checker.getOWLDatatype(name);
   }
 
   /**
-   * Return a typed annotation for the given template string and value. The template string format
-   * is "AT [name]^^[datatype]" and the value is any string.
+   * Given a QuotedEntityChecker, a string value, and a character to split the value string on (or
+   * null), return the value or values as a set of OWLDatatypes.
    *
-   * @param checker used to resolve the annotation property and datatype
-   * @param template the template string
-   * @param value the value for the annotation
-   * @return a new annotation axiom with property and typed literal value
-   * @throws Exception if the annotation property cannot be found
+   * @param tableName name of table
+   * @param checker QuotedEntityChecker to get OWLDatatypes
+   * @param value value or values to parse to datatypes
+   * @param split character to split value on or null
+   * @param rowNum the row number
+   * @param column the column number
+   * @return set of OWLDatatypes
+   * @throws RowParseException if datatype cannot be found or created
    */
-  public static OWLAnnotation getTypedAnnotation(
-      QuotedEntityChecker checker, String template, String value) throws Exception {
-    template = template.substring(2).trim();
-    String name = template.substring(0, template.indexOf("^^")).trim();
-    String typeName = template.substring(template.indexOf("^^") + 2, template.length()).trim();
-    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
-    OWLDatatype datatype = getDatatype(checker, typeName);
-    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, datatype));
+  public static Set<OWLDatatype> getDatatypes(
+      String tableName,
+      QuotedEntityChecker checker,
+      String value,
+      String split,
+      int rowNum,
+      int column)
+      throws RowParseException {
+    List<String> allValues = getAllValues(value, split);
+
+    Set<OWLDatatype> datatypes = new HashSet<>();
+    for (String v : allValues) {
+      String content = QuotedEntityChecker.wrap(v);
+      datatypes.add(getDatatype(tableName, checker, content, rowNum, column));
+    }
+
+    return datatypes;
   }
 
   /**
-   * Return a language tagged annotation for the given template and value. The template string
-   * format is "AL [name]@[lang]" and the value is any string.
+   * Given a QuotedEntityChecker, a string value (maybe separated by a split character), and a split
+   * character (or null), return the value or values as a set of OWLIndividuals.
    *
-   * @param checker used to resolve the annotation property
-   * @param template the template string
-   * @param value the value for the annotation
-   * @return a new annotation axiom with property and language tagged literal
-   * @throws Exception if the annotation property cannot be found
+   * @param checker QuotedEntityChecker to get individuals by label
+   * @param value string of individual or individuals by label or ID
+   * @param split character to split value string on
+   * @return set of OWLIndividuals
    */
-  public static OWLAnnotation getLanguageAnnotation(
-      QuotedEntityChecker checker, String template, String value) throws Exception {
-    template = template.substring(2).trim();
-    String name = template.substring(0, template.indexOf("@")).trim();
-    String lang = template.substring(template.indexOf("@") + 1, template.length()).trim();
-    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
-    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, lang));
+  public static Set<OWLIndividual> getIndividuals(
+      QuotedEntityChecker checker, String value, String split) {
+    if (value == null || value.trim().isEmpty()) {
+      return new HashSet<>();
+    }
+
+    Set<OWLIndividual> individuals = new HashSet<>();
+    List<String> allValues = getAllValues(value, split);
+
+    for (String v : allValues) {
+      individuals.add(checker.getOWLIndividual(v));
+    }
+
+    return individuals;
   }
 
   /**
@@ -375,13 +554,14 @@ public class TemplateHelper {
    * @param template the template string
    * @param value the value for the annotation
    * @return a new annotation axiom with property and an IRI value
-   * @throws Exception if the annotation property cannot be found or the IRI cannot be created
+   * @throws RowParseException if the annotation property cannot be found or the IRI cannot be
+   *     created
    */
   public static OWLAnnotation getIRIAnnotation(
       QuotedEntityChecker checker, String template, String value) throws Exception {
     IRI iri = checker.getIRI(value, true);
     if (iri == null) {
-      throw new Exception(String.format(iriError, value));
+      return null;
     }
     return getIRIAnnotation(checker, template, iri);
   }
@@ -394,7 +574,7 @@ public class TemplateHelper {
    * @param template the template string
    * @param value the IRI value for the annotation
    * @return a new annotation axiom with property and an IRI value
-   * @throws Exception if the annotation property cannot be found
+   * @throws RowParseException if the annotation property cannot be found
    */
   public static OWLAnnotation getIRIAnnotation(
       QuotedEntityChecker checker, String template, IRI value) throws Exception {
@@ -404,72 +584,12 @@ public class TemplateHelper {
   }
 
   /**
-   * Use type, id, and label information to get an entity from the data in a row. Requires either:
-   * an id (default type is owl:Class); an id and type; or a label.
+   * Get a list of the IRIs defined in a set of template tables.
    *
-   * @param checker for looking up labels
-   * @param type the IRI of the type for this entity, or null
-   * @param id the ID for this entity, or null
-   * @param label the label for this entity, or null
-   * @return the entity or null
-   */
-  public static OWLEntity getEntity(
-      QuotedEntityChecker checker, IRI type, String id, String label) {
-
-    IOHelper ioHelper = checker.getIOHelper();
-
-    if (id != null && ioHelper != null) {
-      IRI iri = ioHelper.createIRI(id);
-      if (type == null) {
-        type = IRI.create(owl + "Class");
-      }
-      String t = type.toString();
-      if (t.equals(owl + "Class")) {
-        return dataFactory.getOWLClass(iri);
-      } else if (t.equals(owl + "AnnotationProperty")) {
-        return dataFactory.getOWLAnnotationProperty(iri);
-      } else if (t.equals(owl + "ObjectProperty")) {
-        return dataFactory.getOWLObjectProperty(iri);
-      } else if (t.equals(owl + "DatatypeProperty")) {
-        return dataFactory.getOWLDataProperty(iri);
-      } else if (t.equals(owl + "Datatype")) {
-        return dataFactory.getOWLDatatype(iri);
-      } else {
-        return dataFactory.getOWLNamedIndividual(iri);
-      }
-    }
-
-    if (label != null && type != null) {
-      String t = type.toString();
-      if (t.equals(owl + "Class")) {
-        return checker.getOWLClass(label);
-      } else if (t.equals(owl + "AnnotationProperty")) {
-        return checker.getOWLAnnotationProperty(label);
-      } else if (t.equals(owl + "ObjectProperty")) {
-        return checker.getOWLObjectProperty(label);
-      } else if (t.equals(owl + "DatatypeProperty")) {
-        return checker.getOWLDataProperty(label);
-      } else if (t.equals(owl + "Datatype")) {
-        return checker.getOWLDatatype(label);
-      } else {
-        return checker.getOWLIndividual(label);
-      }
-    }
-
-    if (label != null) {
-      return checker.getOWLEntity(label);
-    }
-
-    return null;
-  }
-
-  /**
-   * Get a list of the IRIs defined in a set of template tables.
-   *
-   * @param tables a map from table names to tables
-   * @param ioHelper used to find entities by name
-   * @return a list of IRIs
-   * @throws Exception when names or templates cannot be handled
+   * @param tables a map from table names to tables
+   * @param ioHelper used to find entities by name
+   * @return a list of IRIs
+   * @throws ColumnException when an ID column does not exist
    */
   public static List<IRI> getIRIs(Map<String, List<List<String>>> tables, IOHelper ioHelper)
       throws Exception {
@@ -489,7 +609,7 @@ public class TemplateHelper {
    * @param rows the table of data
    * @param ioHelper used to find entities by name
    * @return a list of IRIs
-   * @throws Exception when names or templates cannot be handled
+   * @throws ColumnException when an ID column does not exist
    */
   public static List<IRI> getIRIs(String tableName, List<List<String>> rows, IOHelper ioHelper)
       throws Exception {
@@ -507,7 +627,7 @@ public class TemplateHelper {
       }
     }
     if (idColumn == -1) {
-      throw new Exception(String.format(idError, tableName));
+      throw new ColumnException(String.format(idError, tableName));
     }
 
     List<IRI> iris = new ArrayList<>();
@@ -531,6 +651,290 @@ public class TemplateHelper {
     return iris;
   }
 
+  /**
+   * Return a set of language tagged annotations for the given template and value. The template
+   * string format is "AL [name]@[lang]" and the value is any string. Replaced by sets of
+   * annotations to support splits.
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return a new annotation with property and language tagged literal
+   * @throws Exception if the annotation property cannot be found
+   */
+  @Deprecated
+  public static OWLAnnotation getLanguageAnnotation(
+      QuotedEntityChecker checker, String template, String value) throws Exception {
+    OWLAnnotationProperty property = getAnnotationProperty(checker, template, "@");
+    String lang = template.substring(template.indexOf("@") + 1).trim();
+    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, lang));
+  }
+
+  /**
+   * Return a set of language tagged annotations for the given template and value(s). The template
+   * string format is "AL [name]@[lang]" and the value is any string.
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param split the character to split values on
+   * @param value the value for the annotation
+   * @return a set of new annotation(s) with property and language tagged literal
+   * @throws RowParseException if the annotation property cannot be found
+   */
+  public static Set<OWLAnnotation> getLanguageAnnotations(
+      QuotedEntityChecker checker, String template, String split, String value) throws Exception {
+    OWLAnnotationProperty property = getAnnotationProperty(checker, template, "@");
+    String lang = template.substring(template.indexOf("@") + 1).trim();
+
+    Set<OWLAnnotation> annotations = new HashSet<>();
+    List<String> allValues = getAllValues(value, split);
+
+    for (String v : allValues) {
+      annotations.add(dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(v, lang)));
+    }
+
+    return annotations;
+  }
+
+  /**
+   * Given a QuotedEntityChecker, a string value (maybe separated by a split character), and a split
+   * character (or null), return the value or values as a set of OWLLiterals.
+   *
+   * @param tableName name of table
+   * @param checker QuotedEntityChecker to get datatypes
+   * @param value string of literal or literals
+   * @param split character to split value string on
+   * @param rowNum the row number
+   * @param column the column number
+   * @return set of OWLLiterals
+   * @throws RowParseException if row is malformed
+   */
+  public static Set<OWLLiteral> getLiterals(
+      String tableName,
+      QuotedEntityChecker checker,
+      String value,
+      String split,
+      int rowNum,
+      int column)
+      throws RowParseException {
+    Set<OWLLiteral> literals = new HashSet<>();
+    List<String> allValues = getAllValues(value, split);
+
+    for (String v : allValues) {
+      if (v.contains("^^")) {
+        String datatype = v.substring(v.indexOf("^^") + 2);
+        v = v.substring(0, v.indexOf("^^"));
+        OWLDatatype dt = getDatatype(tableName, checker, datatype, rowNum, column);
+        literals.add(dataFactory.getOWLLiteral(v.trim(), dt));
+      } else {
+        literals.add(dataFactory.getOWLLiteral(v.trim()));
+      }
+    }
+
+    return literals;
+  }
+
+  /**
+   * Given a QuotedEntityChecker, a template string, and a template value, return a set of object
+   * property expressions based on the value or values (if SPLIT is included in the template
+   * string). Note that an object property expression can ONLY be another object property or
+   * 'inverse', but this allows support for future data property expressions.
+   *
+   * @param tableName name of table
+   * @param checker QuotedEntityChecker to resolve entities
+   * @param template template string
+   * @param value template value or values
+   * @param rowNum the row number for logging
+   * @param column the column number for logging
+   * @return set of OWLDataPropertyExpressions
+   * @throws RowParseException if row is malformed
+   */
+  public static Set<OWLObjectPropertyExpression> getObjectPropertyExpressions(
+      String tableName,
+      QuotedEntityChecker checker,
+      String template,
+      String value,
+      int rowNum,
+      int column)
+      throws RowParseException {
+    String split = getSplit(template);
+    template = getTemplate(template);
+
+    // Create a parser
+
+    ManchesterOWLSyntaxParser parser =
+        new ManchesterOWLSyntaxParserImpl(
+            new OWLAPIConfigProvider(), OWLManager.getOWLDataFactory());
+    parser.setOWLEntityChecker(checker);
+
+    // Maybe split values
+    List<String> allValues = getAllValues(value, split);
+
+    Set<OWLObjectPropertyExpression> expressions = new HashSet<>();
+    if (template.startsWith("PI")) {
+      // PI indicates its just an IRI
+      for (String v : allValues) {
+        String content = QuotedEntityChecker.wrap(v);
+        OWLObjectProperty property = checker.getOWLObjectProperty(content);
+        expressions.add(property);
+      }
+    } else {
+      for (String v : allValues) {
+        String content = QuotedEntityChecker.wrap(v);
+        // Get the template without identifier by breaking on the first space
+        String sub = template.substring(template.indexOf(" ")).trim().replaceAll("%", content);
+        parser.setStringToParse(sub);
+        logger.info("Parsing expression '%s'", sub);
+        try {
+          expressions.addAll(parser.parseObjectPropertyList());
+        } catch (OWLParserException e) {
+          String cause = getManchesterErrorCause(e);
+          throw new RowParseException(
+              String.format(manchesterParseError, sub, rowNum, column + 1, tableName, cause));
+        }
+      }
+    }
+
+    return expressions;
+  }
+
+  /**
+   * Return a string annotation for the given template string and value. Replaced by sets of
+   * annotations to support splits.
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return a new annotation with property and string literal value
+   * @throws Exception if the annotation property cannot be found
+   */
+  @Deprecated
+  public static OWLAnnotation getStringAnnotation(
+      QuotedEntityChecker checker, String template, String value) throws Exception {
+    String name = template.substring(1).trim();
+    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
+    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value));
+  }
+
+  /**
+   * Return a set of string annotations for the given template string and value(s).
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param split the character to split values on
+   * @param value the value for the annotation
+   * @return a set of new annotation(s) with property and string literal value
+   * @throws RowParseException if the annotation property cannot be found
+   */
+  public static Set<OWLAnnotation> getStringAnnotations(
+      QuotedEntityChecker checker, String template, String split, String value) throws Exception {
+
+    OWLAnnotationProperty property;
+    if (template.equals("LABEL")) {
+      // Handle special LABEL case
+      property = dataFactory.getRDFSLabel();
+    } else {
+      String name = template.substring(1).trim();
+      property = getAnnotationProperty(checker, name);
+    }
+
+    Set<OWLAnnotation> annotations = new HashSet<>();
+    if (split != null) {
+      String[] values = value.split(Pattern.quote(split));
+      for (String v : values) {
+        annotations.add(dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(v)));
+      }
+    } else {
+      annotations.add(dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value)));
+    }
+
+    return annotations;
+  }
+
+  /**
+   * Return a set of typed annotations for the given template string and value. The template string
+   * format is "AT [name]^^[datatype]" and the value is any string. Replaced by sets of annotations
+   * to support splits.
+   *
+   * @param checker used to resolve the annotation property and datatype
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return a new annotation with property and typed literal value
+   * @throws Exception if the annotation property cannot be found
+   */
+  @Deprecated
+  public static OWLAnnotation getTypedAnnotation(
+      QuotedEntityChecker checker, String template, String value) throws Exception {
+    OWLAnnotationProperty property = getAnnotationProperty(checker, template, "^^");
+    String typeName = template.substring(template.indexOf("^^") + 2).trim();
+    OWLDatatype datatype = getDatatype(checker, typeName);
+    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, datatype));
+  }
+
+  /**
+   * Return a set of typed annotations for the given template string and value(s). The template
+   * string format is "AT [name]^^[datatype]" and the value is any string.
+   *
+   * @param tableName name of table
+   * @param checker used to resolve the annotation property and datatype
+   * @param template the template string
+   * @param split the character to split values on
+   * @param value the value for the annotation
+   * @param rowNum the row number
+   * @param column the column number
+   * @return a set of new annotation(s) with property and typed literal value
+   * @throws RowParseException if the annotation property cannot be found
+   */
+  public static Set<OWLAnnotation> getTypedAnnotations(
+      String tableName,
+      QuotedEntityChecker checker,
+      String template,
+      String split,
+      String value,
+      int rowNum,
+      int column)
+      throws Exception {
+    OWLAnnotationProperty property = getAnnotationProperty(checker, template, "^^");
+    String typeName = template.substring(template.indexOf("^^") + 2).trim();
+    OWLDatatype datatype = getDatatype(tableName, checker, typeName, rowNum, column);
+
+    Set<OWLAnnotation> annotations = new HashSet<>();
+    if (split != null) {
+      String[] values = value.split(Pattern.quote(split));
+      for (String v : values) {
+        annotations.add(
+            dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(v, datatype)));
+      }
+    } else {
+      annotations.add(
+          dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, datatype)));
+    }
+
+    return annotations;
+  }
+
+  /**
+   * Given a QuotedEntityChecker, a value, and a split character (or null), return the value (or
+   * values) as a set of IRIs.
+   *
+   * @param checker QuotedEntityChecker to get IRIs
+   * @param value value (or values) to parse to IRI
+   * @param split character to split value on
+   * @return set of IRIs
+   */
+  public static Set<IRI> getValueIRIs(QuotedEntityChecker checker, String value, String split) {
+    List<String> allValues = getAllValues(value, split);
+
+    Set<IRI> iris = new HashSet<>();
+    for (String v : allValues) {
+      IRI iri = checker.getIRI(v.trim(), true);
+      if (iri != null) {
+        iris.add(iri);
+      }
+    }
+    return iris;
+  }
+
   /**
    * Read comma-separated values from a path to a list of lists of strings.
    *
@@ -622,6 +1026,241 @@ public class TemplateHelper {
     return readXSV(reader, '\t');
   }
 
+  /**
+   * Return true if the template string is valid, false otherwise.
+   *
+   * @param template the template string to check
+   * @return true if valid, false otherwise
+   */
+  public static boolean validateTemplateString(String template) {
+    template = template.trim();
+    if (template.equals("ID")) {
+      return true;
+    } else if (template.equals("LABEL")) {
+      return true;
+    } else if (template.equals("TYPE")) {
+      return true;
+    } else if (template.equals("CLASS_TYPE")) {
+      return true;
+    } else if (template.equals("PROPERTY_TYPE")) {
+      return true;
+    } else if (template.equals("DOMAIN")) {
+      return true;
+    } else if (template.equals("RANGE")) {
+      return true;
+    } else if (template.matches("^CHARACTERISTIC( SPLIT=.+)?$")) {
+      // CHARACTERISTIC can have a split
+      // Should only be followed by SPLIT, nothing else
+      return true;
+    } else if (template.matches("^INDIVIDUAL_TYPE( SPLIT=.+)?$")) {
+      // INDIVIDUAL_TYPE can have a split
+      // Should only be followed by SPLIT, nothing else
+      return true;
+    } else if (template.matches("^>{0,2}A[LTI]? .*")) {
+      // Annotations can have one or two > (nested)
+      // And can be A, AL, AT, or AI always followed by space
+      return true;
+    } else if (template.matches("^>?(C .*|CI.?|[SED]C .*)")) {
+      // Classes can have one > (annotation on previous axiom - legacy support)
+      // Can be C, CI (does not need to be followed by space), SC, EC, or DC
+      return true;
+    } else if (template.matches("^(P .*|PI.?|[SEDI]P .*)")) {
+      // Properties can be P, PI (does not need to be followed by space), SP, EP, DP, or IP
+      return true;
+    } else
+      // Individuals can be I, II (does not need to be followed by space), SI, or DI
+      return template.matches("^(I .*|II.?|[SD]I .*)");
+
+    // TODO - future support for DT datatype axioms
+  }
+
+  /**
+   * Given a Manchester class expression parser and a content string, try to parse the content
+   * string. Throw a detailed exception message if parsing fails.
+   *
+   * @param tableName name of table
+   * @param parser ManchesterOWLSyntaxClassExpressionParser to parse string
+   * @param content class expression string to parse
+   * @param rowNum the row number for logging
+   * @param column the column number for logging
+   * @return OWLClassExpression representation of the string
+   * @throws RowParseException if string cannot be parsed for any reason
+   */
+  protected static OWLClassExpression tryParse(
+      String tableName,
+      ManchesterOWLSyntaxClassExpressionParser parser,
+      String content,
+      int rowNum,
+      int column)
+      throws RowParseException {
+    OWLClassExpression expr;
+    content = content.trim();
+    logger.info(String.format("Parsing expression: %s", content));
+    try {
+      expr = parser.parse(content);
+    } catch (OWLParserException e) {
+      String cause = getManchesterErrorCause(e);
+      throw new RowParseException(
+          String.format(manchesterParseError, content, rowNum, column + 1, tableName, cause));
+    }
+    return expr;
+  }
+
+  /**
+   * Given an OWLParserException, determine if we can identify the offending term. Return that as
+   * the cause.
+   *
+   * @param e exception to get cause of
+   * @return String cause of exception
+   */
+  private static String getManchesterErrorCause(OWLParserException e) {
+    String cause = e.getMessage();
+    String pattern = ".*Encountered ([^ ]*|'.*') at line.*";
+    Pattern p = Pattern.compile(pattern);
+    Matcher m = p.matcher(e.getMessage());
+    if (m.find()) {
+      if (m.group(1).startsWith("'")) {
+        return "encountered unknown " + m.group(1);
+      } else {
+        return String.format("encountered unknown '%s'", m.group(1));
+      }
+    }
+    return cause;
+  }
+
+  /**
+   * Given a value (maybe separated by a split character) and a split character (or null), return
+   * the string as a list of values. If there is no split character, the value is the only element
+   * of the list.
+   *
+   * @param value string to split
+   * @param split character to split on, or null
+   * @return values as list
+   */
+  private static List<String> getAllValues(String value, String split) {
+    List<String> allValues = new ArrayList<>();
+    if (split != null) {
+      String[] values = value.split(Pattern.quote(split));
+      for (String v : values) {
+        allValues.add(v.trim());
+      }
+    } else {
+      allValues.add(value.trim());
+    }
+    return allValues;
+  }
+
+  /**
+   * Given a quoted entity checker, a template string, and a character that separates the template
+   * from a tag, return the annotation property.
+   *
+   * @param checker QuotedEntityChecker to resolve properties
+   * @param template template string
+   * @param chr character to split template string
+   * @return OWLAnnotationProperty
+   * @throws RowParseException on issue resolving property
+   */
+  private static OWLAnnotationProperty getAnnotationProperty(
+      QuotedEntityChecker checker, String template, String chr) throws Exception {
+    template = template.substring(2).trim();
+    String name = template.substring(0, template.indexOf(chr)).trim();
+    return getAnnotationProperty(checker, name);
+  }
+
+  /**
+   * Given a table name, a template string, a value string, a parser, a row number, and a column
+   * number, attempt to resolve a class expression for that template string and value.
+   *
+   * @param tableName name of table
+   * @param template template string
+   * @param value template value
+   * @param parser Machester parser
+   * @param rowNum row number of template value
+   * @param col column number of template string
+   * @return OWLClassExpression from template
+   * @throws RowParseException on issue resolving names
+   */
+  private static OWLClassExpression getClassExpression(
+      String tableName,
+      String template,
+      String value,
+      ManchesterOWLSyntaxClassExpressionParser parser,
+      int rowNum,
+      int col)
+      throws RowParseException {
+    String content = QuotedEntityChecker.wrap(value);
+    // Get the template without identifier by breaking on the first space
+    String sub;
+    if (template.contains("%")) {
+      sub = template.substring(template.indexOf(" ")).replaceAll("%", content);
+    } else {
+      sub = content;
+    }
+    return tryParse(tableName, parser, sub, rowNum, col);
+  }
+
+  /**
+   * Given a tempalte string, return the split character if it exists.
+   *
+   * @param template template string
+   * @return split character or null
+   */
+  private static String getSplit(String template) {
+    if (template.contains("SPLIT=")) {
+      return template.substring(template.indexOf("SPLIT=") + 6).trim();
+    }
+    return null;
+  }
+
+  /**
+   * Given a template string, return the template without a split (if it exists).
+   *
+   * @param template template string
+   * @return template string, maybe without SPLIT
+   */
+  private static String getTemplate(String template) {
+    if (template.contains("SPLIT=")) {
+      return template.substring(0, template.indexOf("SPLIT=")).trim();
+    }
+    return template.trim();
+  }
+
+  /**
+   * Given a checker, a template string, and a value for the template, return an IRI annotation.
+   *
+   * @param tableName name of table
+   * @param checker QuotedEntityChecker to resolve entities
+   * @param template template string
+   * @param value value to use with the template string
+   * @param rowNum the row number for logging
+   * @param column the column number for logging
+   * @return OWLAnnotation created from template and value
+   * @throws RowParseException if entities cannot be resolved
+   */
+  private static OWLAnnotation maybeGetIRIAnnotation(
+      String tableName,
+      QuotedEntityChecker checker,
+      String template,
+      String value,
+      int rowNum,
+      int column)
+      throws Exception {
+    IRI iri = checker.getIRI(value, true);
+    if (iri != null) {
+      return getIRIAnnotation(checker, template, iri);
+    } else {
+      throw new RowParseException(String.format(iriError, rowNum, column + 1, tableName, value));
+    }
+  }
+
+  /**
+   * Given a Reader and a separator character, return the contents of the table as a list of rows.
+   *
+   * @param reader a reader to read data from
+   * @param separator separator character
+   * @return a list of lists of strings
+   * @throws IOException on file reading problems
+   */
   private static List<List<String>> readXSV(Reader reader, char separator) throws IOException {
     CSVReader csv =
         new CSVReaderBuilder(reader)
@@ -635,16 +1274,208 @@ public class TemplateHelper {
     return rows;
   }
 
+  /**
+   * Get a set of annotation axioms for an OWLEntity. Supports axiom annotations and axiom
+   * annotation annotations.
+   *
+   * @deprecated TemplateOperation replaced with Template class
+   * @param entity OWLEntity to annotation
+   * @param annotations Set of OWLAnnotations
+   * @param nested Map with top-level OWLAnnotation as key and another map (axiom OWLAnnotation, set
+   *     of axiom annotation OWLAnnotations) as value
+   * @return Set of OWLAnnotationAssertionAxioms
+   */
+  @Deprecated
+  public static Set<OWLAnnotationAssertionAxiom> getAnnotationAxioms(
+      OWLEntity entity,
+      Set<OWLAnnotation> annotations,
+      Map<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> nested) {
+    Set<OWLAnnotationAssertionAxiom> axioms = new HashSet<>();
+    // Create basic annotations
+    for (OWLAnnotation annotation : annotations) {
+      axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(entity.getIRI(), annotation));
+    }
+    // Create annotations with axiom annotations
+    for (Entry<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> annotationPlusAxioms :
+        nested.entrySet()) {
+      OWLAnnotation annotation = annotationPlusAxioms.getKey();
+      Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
+      // For each annotation with its axiom annotations ...
+      for (Entry<OWLAnnotation, Set<OWLAnnotation>> nestedSet :
+          annotationPlusAxioms.getValue().entrySet()) {
+        OWLAnnotation axiomAnnotation = nestedSet.getKey();
+        // Check if there are annotations on the axiom annotations, and add those
+        Set<OWLAnnotation> axiomAnnotationAnnotations = nestedSet.getValue();
+        if (axiomAnnotationAnnotations.isEmpty()) {
+          axiomAnnotations.add(axiomAnnotation);
+        } else {
+          OWLAnnotationProperty property = axiomAnnotation.getProperty();
+          OWLAnnotationValue value = axiomAnnotation.getValue();
+          axiomAnnotations.add(
+              dataFactory.getOWLAnnotation(property, value, axiomAnnotationAnnotations));
+        }
+      }
+      axioms.add(
+          dataFactory.getOWLAnnotationAssertionAxiom(
+              entity.getIRI(), annotation, axiomAnnotations));
+    }
+    return axioms;
+  }
+
+  /**
+   * Given a set of rows, the row number, and the column number, get the content in the column for
+   * the row. If there are any issues, return an empty string. If the cell is empty, return null.
+   *
+   * @deprecated TemplateOperation replaced with Template class
+   * @param rows list of rows (lists of strings)
+   * @param row row number to get ID of
+   * @param column column number
+   * @return content, null, or empty string.
+   */
+  @Deprecated
+  public static String getCellContent(List<List<String>> rows, int row, Integer column) {
+    String id = null;
+    if (column != null && column != -1) {
+      try {
+        id = rows.get(row).get(column);
+      } catch (IndexOutOfBoundsException e) {
+        return "";
+      }
+      if (id == null || id.trim().isEmpty()) {
+        return "";
+      }
+    }
+    return id;
+  }
+
+  /**
+   * Use type, id, and label information to get an entity from the data in a row. Requires either:
+   * an id (default type is owl:Class); an id and type; or a label.
+   *
+   * @deprecated TemplateOperation replaced with Template class
+   * @param checker for looking up labels
+   * @param type the IRI of the type for this entity, or null
+   * @param id the ID for this entity, or null
+   * @param label the label for this entity, or null
+   * @return the entity or null
+   */
+  @Deprecated
+  public static OWLEntity getEntity(
+      QuotedEntityChecker checker, IRI type, String id, String label) {
+
+    IOHelper ioHelper = checker.getIOHelper();
+
+    if (id != null && ioHelper != null) {
+      IRI iri = ioHelper.createIRI(id);
+      if (type == null) {
+        type = IRI.create(OWL_CLASS);
+      }
+      String t = type.toString();
+      switch (t) {
+        case OWL_CLASS:
+          return dataFactory.getOWLClass(iri);
+        case OWL_ANNOTATION_PROPERTY:
+          return dataFactory.getOWLAnnotationProperty(iri);
+        case OWL_OBJECT_PROPERTY:
+          return dataFactory.getOWLObjectProperty(iri);
+        case OWL_DATA_PROPERTY:
+          return dataFactory.getOWLDataProperty(iri);
+        case OWL_DATATYPE:
+          return dataFactory.getOWLDatatype(iri);
+        default:
+          return dataFactory.getOWLNamedIndividual(iri);
+      }
+    }
+
+    if (label != null && type != null) {
+      String t = type.toString();
+      switch (t) {
+        case OWL_CLASS:
+          return checker.getOWLClass(label);
+        case OWL_ANNOTATION_PROPERTY:
+          return checker.getOWLAnnotationProperty(label);
+        case OWL_OBJECT_PROPERTY:
+          return checker.getOWLObjectProperty(label);
+        case OWL_DATA_PROPERTY:
+          return checker.getOWLDataProperty(label);
+        case OWL_DATATYPE:
+          return checker.getOWLDatatype(label);
+        default:
+          return checker.getOWLIndividual(label);
+      }
+    }
+
+    if (label != null) {
+      return checker.getOWLEntity(label);
+    }
+
+    return null;
+  }
+
+  /**
+   * Get a set of OWLAxioms (subclass or equivalent) for an OWLClass. Supports axiom annotations.
+   *
+   * @deprecated TemplateOperation replaced with Template class
+   * @param cls OWLClass to add axioms to
+   * @param classType subclass or equivalent
+   * @param classExpressions Set of OWLClassExpressions
+   * @param annotatedExpressions Map of annotated OWLClassExpressions and the Set of OWLAnnotations
+   * @return Set of OWLAxioms, or null if classType is not subclass or equivalent
+   * @throws Exception if classType is not subclass or equivalent
+   */
+  @Deprecated
+  public static Set<OWLAxiom> getLogicalAxioms(
+      OWLClass cls,
+      String classType,
+      Set<OWLClassExpression> classExpressions,
+      Map<OWLClassExpression, Set<OWLAnnotation>> annotatedExpressions)
+      throws Exception {
+    Set<OWLAxiom> axioms = new HashSet<>();
+    switch (classType) {
+      case "subclass":
+        for (OWLClassExpression expression : classExpressions) {
+          axioms.add(dataFactory.getOWLSubClassOfAxiom(cls, expression));
+        }
+        for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
+            annotatedExpressions.entrySet()) {
+          axioms.add(
+              dataFactory.getOWLSubClassOfAxiom(cls, annotatedEx.getKey(), annotatedEx.getValue()));
+        }
+        return axioms;
+      case "equivalent":
+        // Since it's an intersection, all annotations will be added to the same axiom
+        Set<OWLAnnotation> annotations = new HashSet<>();
+        for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
+            annotatedExpressions.entrySet()) {
+          classExpressions.add(annotatedEx.getKey());
+          annotations.addAll(annotatedEx.getValue());
+        }
+        OWLObjectIntersectionOf intersection =
+            dataFactory.getOWLObjectIntersectionOf(classExpressions);
+        OWLAxiom axiom;
+        if (!annotations.isEmpty()) {
+          axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection, annotations);
+        } else {
+          axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection);
+        }
+        return Sets.newHashSet(axiom);
+      default:
+        throw new ColumnException(String.format(classTypeError, classType));
+    }
+  }
+
   /**
    * Given a template string, a cell value, and an empty list, fill the list with any number of
    * values based on a SPLIT character, then return the template string without SPLIT. If there are
    * no SPLITs, only add the original cell to the values.
    *
+   * @deprecated TemplateOperation replaced with Template class
    * @param template template string
    * @param cell cell contents
    * @param values empty list to fill
    * @return template string without SPLIT
    */
+  @Deprecated
   public static String processSplit(String template, String cell, List<String> values) {
     // If the template contains SPLIT=X,
     // then split the cell value
@@ -652,8 +1483,8 @@ public class TemplateHelper {
     Pattern splitter = Pattern.compile("SPLIT=(\\S+)");
     Matcher matcher = splitter.matcher(template);
     if (matcher.find()) {
-      Pattern split = Pattern.compile(Pattern.quote(matcher.group(1)));
-      values.addAll(Arrays.asList(split.split(cell)));
+      Pattern split = Pattern.compile(matcher.group(1));
+      values.addAll(Arrays.asList(split.split(Pattern.quote(cell))));
       template = matcher.replaceAll("").trim();
     } else {
       values.add(cell);
diff --git a/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java b/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
index 50782bf1..435a0017 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
@@ -93,16 +93,6 @@ public class TemplateOperation {
           + "in table \"%1$s\": "
           + "%7$s";
 
-  /**
-   * Error message when a template cannot be understood. Expects: table name, column number, column
-   * name, template.
-   */
-  private static String unknownTemplateError =
-      NS
-          + "UNKNOWN TEMPLATE ERROR could not interpret template string \"%4$s\" "
-          + "for column %2$d (\"%3$s\") "
-          + "in table \"%1$s\".";
-
   /**
    * Error message when a class type is not recognized. Should be "subclass" or "equivalent".
    * Expects: table name, row number, row id, value.
@@ -110,6 +100,90 @@ public class TemplateOperation {
   private static String unknownTypeError =
       NS + "UNKNOWN TYPE ERROR \"%4$s\" for row %2$d (\"%3$s\") in table \"%1$s\".";
 
+  /**
+   * Get the default template options.
+   *
+   * @return map of template options
+   */
+  public static Map<String, String> getDefaultOptions() {
+    Map<String, String> options = new HashMap<>();
+    options.put("force", "false");
+    return options;
+  }
+
+  /**
+   * Given an OWLOntology, an IOHelper, a table name, and the table contents, use the table as the
+   * template to generate an output OWLOntology.
+   *
+   * @param inputOntology OWLOntology to use to get existing entities
+   * @param ioHelper IOHelper to resolve prefixes
+   * @param tableName name of the table for error reporting
+   * @param table List of rows (lists of cells) in the template
+   * @return new OWLOntology created from the template
+   * @throws Exception on any issue
+   */
+  public static OWLOntology template(
+      OWLOntology inputOntology, IOHelper ioHelper, String tableName, List<List<String>> table)
+      throws Exception {
+    return template(inputOntology, ioHelper, tableName, table, getDefaultOptions());
+  }
+
+  /**
+   * Given an OWLOntology, an IOHelper, a table name, the table contents, and a map of template
+   * options, use the table as the template to generate an output OWLOntology.
+   *
+   * @param inputOntology OWLOntology to use to get existing entities
+   * @param ioHelper IOHelper to resolve prefixes
+   * @param tableName name of the table for error reporting
+   * @param table List of rows (lists of cells) in the template
+   * @param options map of template options
+   * @return new OWLOntology created from the template
+   * @throws Exception on any issue
+   */
+  public static OWLOntology template(
+      OWLOntology inputOntology,
+      IOHelper ioHelper,
+      String tableName,
+      List<List<String>> table,
+      Map<String, String> options)
+      throws Exception {
+    Template template = new Template(tableName, table, inputOntology, ioHelper);
+    boolean force = OptionsHelper.optionIsTrue(options, "force");
+    return template.generateOutputOntology(null, force);
+  }
+
+  /**
+   * Given an OWLOntology, an IOHelper, a map of table names to table contents, and a map of
+   * template options, use the tables as templates to generate a merged output ontology.
+   *
+   * @param inputOntology OWLOntology to use to get existing entities
+   * @param ioHelper IOHelper to resolve prefixes
+   * @param tables table names to table contents
+   * @param options map of template options
+   * @return new OWLOntology created from merged template ontologies
+   * @throws Exception on any issue
+   */
+  public static OWLOntology template(
+      OWLOntology inputOntology,
+      IOHelper ioHelper,
+      Map<String, List<List<String>>> tables,
+      Map<String, String> options)
+      throws Exception {
+    QuotedEntityChecker checker = null;
+    OWLOntology intermediate = inputOntology;
+    List<OWLOntology> outputOntologies = new ArrayList<>();
+    for (Map.Entry<String, List<List<String>>> t : tables.entrySet()) {
+      Template template = new Template(t.getKey(), t.getValue(), intermediate, ioHelper, checker);
+      // Update the checker with new labels
+      checker = template.getChecker();
+      boolean force = OptionsHelper.optionIsTrue(options, "force");
+      // Update the intermediate ontology as the generated ontology
+      intermediate = template.generateOutputOntology(null, force);
+      outputOntologies.add(intermediate);
+    }
+    return MergeOperation.merge(outputOntologies);
+  }
+
   /**
    * Find an annotation property with the given name or create one.
    *
@@ -118,6 +192,7 @@ public class TemplateOperation {
    * @return an annotation property
    * @throws Exception if the name cannot be resolved
    */
+  @Deprecated
   public static OWLAnnotationProperty getAnnotationProperty(
       QuotedEntityChecker checker, String name) throws Exception {
     return TemplateHelper.getAnnotationProperty(checker, name);
@@ -131,8 +206,14 @@ public class TemplateOperation {
    * @return a datatype
    * @throws Exception if the name cannot be resolved
    */
+  @Deprecated
   public static OWLDatatype getDatatype(QuotedEntityChecker checker, String name) throws Exception {
-    return TemplateHelper.getDatatype(checker, name);
+    OWLDatatype datatype = TemplateHelper.getDatatype(checker, name);
+    if (datatype == null) {
+      throw new Exception(
+          String.format("%sDATATYPE ERROR could not find datatype for '%s'", NS, name));
+    }
+    return datatype;
   }
 
   /**
@@ -144,6 +225,7 @@ public class TemplateOperation {
    * @return a new annotation with property and string literal value
    * @throws Exception if the annotation property cannot be found
    */
+  @Deprecated
   public static OWLAnnotation getStringAnnotation(
       QuotedEntityChecker checker, String template, String value) throws Exception {
     return TemplateHelper.getStringAnnotation(checker, template, value);
@@ -159,6 +241,7 @@ public class TemplateOperation {
    * @return a new annotation axiom with property and typed literal value
    * @throws Exception if the annotation property cannot be found
    */
+  @Deprecated
   public static OWLAnnotation getTypedAnnotation(
       QuotedEntityChecker checker, String template, String value) throws Exception {
     return TemplateHelper.getTypedAnnotation(checker, template, value);
@@ -174,6 +257,7 @@ public class TemplateOperation {
    * @return a new annotation axiom with property and language tagged literal
    * @throws Exception if the annotation property cannot be found
    */
+  @Deprecated
   public static OWLAnnotation getLanguageAnnotation(
       QuotedEntityChecker checker, String template, String value) throws Exception {
     return TemplateHelper.getLanguageAnnotation(checker, template, value);
@@ -189,6 +273,7 @@ public class TemplateOperation {
    * @return a new annotation axiom with property and an IRI value
    * @throws Exception if the annotation property cannot be found
    */
+  @Deprecated
   public static OWLAnnotation getIRIAnnotation(
       QuotedEntityChecker checker, String template, IRI value) throws Exception {
     return TemplateHelper.getIRIAnnotation(checker, template, value);
@@ -204,6 +289,7 @@ public class TemplateOperation {
    * @param label the label for this entity, or null
    * @return the entity or null
    */
+  @Deprecated
   public static OWLEntity getEntity(
       QuotedEntityChecker checker, IRI type, String id, String label) {
     return TemplateHelper.getEntity(checker, type, id, label);
@@ -217,6 +303,7 @@ public class TemplateOperation {
    * @return a list of IRIs
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   public static List<IRI> getIRIs(Map<String, List<List<String>>> tables, IOHelper ioHelper)
       throws Exception {
     return TemplateHelper.getIRIs(tables, ioHelper);
@@ -231,37 +318,12 @@ public class TemplateOperation {
    * @return a list of IRIs
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   public static List<IRI> getIRIs(String tableName, List<List<String>> rows, IOHelper ioHelper)
       throws Exception {
     return TemplateHelper.getIRIs(tableName, rows, ioHelper);
   }
 
-  /**
-   * Return true if the template string is valid, false otherwise.
-   *
-   * @param template the template string to check
-   * @return true if valid, false otherwise
-   */
-  public static boolean validateTemplateString(String template) {
-    template = template.trim();
-    if (template.equals("ID")) {
-      return true;
-    }
-    if (template.equals("LABEL")) {
-      return true;
-    }
-    if (template.equals("TYPE")) {
-      return true;
-    }
-    if (template.equals("CLASS_TYPE")) {
-      return true;
-    }
-    if (template.matches("^(>?C|>{0,2}A[LTI]?) .*")) {
-      return true;
-    }
-    return template.equals("CI");
-  }
-
   /**
    * Use a table to generate an ontology. With this signature we use all defaults.
    *
@@ -269,6 +331,7 @@ public class TemplateOperation {
    * @return a new ontology generated from the table
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   public static OWLOntology template(Map<String, List<List<String>>> tables) throws Exception {
     return template(tables, null, null, null);
   }
@@ -281,6 +344,7 @@ public class TemplateOperation {
    * @return a new ontology generated from the table
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   public static OWLOntology template(
       Map<String, List<List<String>>> tables, OWLOntology inputOntology) throws Exception {
     return template(tables, inputOntology, null, null);
@@ -295,6 +359,7 @@ public class TemplateOperation {
    * @return a new ontology generated from the table
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   public static OWLOntology template(
       Map<String, List<List<String>>> tables,
       OWLOntology inputOntology,
@@ -312,6 +377,7 @@ public class TemplateOperation {
    * @return a new ontology generated from the table
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   public static OWLOntology template(
       Map<String, List<List<String>>> tables, OWLOntology inputOntology, IOHelper ioHelper)
       throws Exception {
@@ -332,6 +398,7 @@ public class TemplateOperation {
    * @return a new ontology generated from the table
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   public static OWLOntology template(
       Map<String, List<List<String>>> tables,
       OWLOntology inputOntology,
@@ -365,11 +432,7 @@ public class TemplateOperation {
         if (template.isEmpty()) {
           continue;
         }
-        if (!validateTemplateString(template)) {
-          throw new Exception(
-              String.format(
-                  unknownTemplateError, tableName, column + 1, headers.get(column), template));
-        }
+
         if (template.equals("ID")) {
           idColumn = column;
         }
@@ -441,6 +504,7 @@ public class TemplateOperation {
    * @param checker used to find annotation properties by name
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   private static void addAnnotations(
       OWLOntology outputOntology,
       String tableName,
@@ -515,7 +579,6 @@ public class TemplateOperation {
         if (template.equals("LABEL")) {
           label = value;
           lastAxiomAnnotation = null;
-          lastAnnotation = TemplateHelper.getStringAnnotation(checker, "A rdfs:label", value);
           annotations.add(lastAnnotation);
         } else if (template.equals("TYPE")) {
           OWLEntity entity = checker.getOWLEntity(value);
@@ -615,6 +678,7 @@ public class TemplateOperation {
    * @param parser used parse expressions
    * @throws Exception when names or templates cannot be handled
    */
+  @Deprecated
   private static void addLogic(
       OWLOntology ontology,
       String tableName,
@@ -725,8 +789,8 @@ public class TemplateOperation {
           } else {
             annotations = new HashSet<>();
           }
-          annotations.add(
-              TemplateHelper.getStringAnnotation(checker, template.substring(1).trim(), cell));
+          // annotations.add(
+          // TemplateHelper.getStringAnnotation(checker, template.substring(1).trim(), cell));
           annotatedExpressions.put(lastExpression, annotations);
           // Remove to prevent duplication
           classExpressions.remove(lastExpression);
@@ -762,4 +826,14 @@ public class TemplateOperation {
       ontology.getOWLOntologyManager().addAxioms(ontology, axioms);
     }
   }
+  /**
+   * Return true if the template string is valid, false otherwise.
+   *
+   * @param template the template string to check
+   * @return true if valid, false otherwise
+   */
+  @Deprecated
+  public static boolean validateTemplateString(String template) {
+    return TemplateHelper.validateTemplateString(template);
+  }
 }
diff --git a/robot-core/src/main/java/org/obolibrary/robot/exceptions/ColumnException.java b/robot-core/src/main/java/org/obolibrary/robot/exceptions/ColumnException.java
new file mode 100644
index 00000000..dac7e62a
--- /dev/null
+++ b/robot-core/src/main/java/org/obolibrary/robot/exceptions/ColumnException.java
@@ -0,0 +1,25 @@
+package org.obolibrary.robot.exceptions;
+
+/** Template column cannot be parsed. */
+public class ColumnException extends Exception {
+  private static final long serialVersionUID = -2799779465303691943L;
+
+  /**
+   * Throw new ColumnException with message.
+   *
+   * @param s message
+   */
+  public ColumnException(String s) {
+    super(s);
+  }
+
+  /**
+   * Throw new ColumnException with message and exception cause.
+   *
+   * @param s message
+   * @param e cause
+   */
+  public ColumnException(String s, Exception e) {
+    super(s, e);
+  }
+}
diff --git a/robot-core/src/main/java/org/obolibrary/robot/exceptions/RowParseException.java b/robot-core/src/main/java/org/obolibrary/robot/exceptions/RowParseException.java
new file mode 100644
index 00000000..dbd65af5
--- /dev/null
+++ b/robot-core/src/main/java/org/obolibrary/robot/exceptions/RowParseException.java
@@ -0,0 +1,25 @@
+package org.obolibrary.robot.exceptions;
+
+/** Template row cannot be parsed. */
+public class RowParseException extends Exception {
+  private static final long serialVersionUID = -646778731149993824L;
+
+  /**
+   * Throw new RowParseException with message.
+   *
+   * @param s message
+   */
+  public RowParseException(String s) {
+    super(s);
+  }
+
+  /**
+   * Throw new RowParseException with message amd cause.
+   *
+   * @param s message
+   * @param e cause
+   */
+  public RowParseException(String s, Exception e) {
+    super(s, e);
+  }
+}


Test patch:
diff --git a/robot-core/src/test/java/org/obolibrary/robot/QuotedEntityCheckerTest.java b/robot-core/src/test/java/org/obolibrary/robot/QuotedEntityCheckerTest.java
index 768d7458..d8a5f9e9 100644
--- a/robot-core/src/test/java/org/obolibrary/robot/QuotedEntityCheckerTest.java
+++ b/robot-core/src/test/java/org/obolibrary/robot/QuotedEntityCheckerTest.java
@@ -2,7 +2,9 @@ package org.obolibrary.robot;
 
 import static junit.framework.TestCase.assertEquals;
 
+import org.junit.Assert;
 import org.junit.Test;
+import org.semanticweb.owlapi.manchestersyntax.parser.ManchesterOWLSyntaxClassExpressionParser;
 import org.semanticweb.owlapi.model.*;
 import org.semanticweb.owlapi.util.SimpleShortFormProvider;
 
@@ -13,6 +15,15 @@ import org.semanticweb.owlapi.util.SimpleShortFormProvider;
  */
 public class QuotedEntityCheckerTest extends CoreTest {
 
+  /** Test wrapping and escaping names. */
+  @Test
+  public void testEscaping() {
+    Assert.assertEquals("testone", QuotedEntityChecker.wrap("testone"));
+    Assert.assertEquals("'test one'", QuotedEntityChecker.wrap("test one"));
+    Assert.assertEquals(
+        "5-bromo-2\\'-deoxyuridine", QuotedEntityChecker.wrap("5-bromo-2'-deoxyuridine"));
+  }
+
   /**
    * Test resolving a label from an import.
    *
@@ -36,4 +47,44 @@ public class QuotedEntityCheckerTest extends CoreTest {
       throw new Exception("Class 'test one' does not exist.");
     }
   }
+
+  /**
+   * Test the QuotedEntityChecker.
+   *
+   * @throws Exception if entities cannot be found
+   */
+  @Test
+  public void testChecker() throws Exception {
+    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
+    QuotedEntityChecker checker = new QuotedEntityChecker();
+    checker.addProperty(dataFactory.getRDFSLabel());
+    checker.addAll(simpleParts);
+
+    IRI iri = IRI.create(base + "simple.owl#test1");
+    OWLClass cls = dataFactory.getOWLClass(iri);
+    Assert.assertEquals(cls, checker.getOWLClass("test one"));
+    Assert.assertEquals(cls, checker.getOWLClass("'test one'"));
+    Assert.assertEquals(cls, checker.getOWLClass("Test 1"));
+    Assert.assertEquals(iri, checker.getIRI("test one", false));
+
+    IOHelper ioHelper = new IOHelper();
+    iri = ioHelper.createIRI("GO:XXXX");
+    cls = dataFactory.getOWLClass(iri);
+    checker.setIOHelper(ioHelper);
+    Assert.assertEquals(cls, checker.getOWLClass("GO:XXXX"));
+
+    System.out.println("PARSER");
+    ManchesterOWLSyntaxClassExpressionParser parser =
+        new ManchesterOWLSyntaxClassExpressionParser(
+            dataFactory, checker
+            // new org.semanticweb.owlapi.expression.ShortFormEntityChecker(
+            //    new org.semanticweb.owlapi.util.
+            //        BidirectionalShortFormProviderAdapter(
+            //            ioHelper.getPrefixManager()))
+            );
+    // assertEquals(cls, parser.parse("'test one'"));
+    Assert.assertEquals(cls, parser.parse("GO:XXXX"));
+    // checker.add(cls, "%");
+    // assertEquals("", parser.parse("%"));
+  }
 }
diff --git a/robot-core/src/test/java/org/obolibrary/robot/TemplateHelperTest.java b/robot-core/src/test/java/org/obolibrary/robot/TemplateHelperTest.java
new file mode 100644
index 00000000..a5216845
--- /dev/null
+++ b/robot-core/src/test/java/org/obolibrary/robot/TemplateHelperTest.java
@@ -0,0 +1,245 @@
+package org.obolibrary.robot;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.Sets;
+import java.io.IOException;
+import java.util.Set;
+import org.junit.Before;
+import org.junit.Test;
+import org.semanticweb.owlapi.apibinding.OWLManager;
+import org.semanticweb.owlapi.manchestersyntax.parser.ManchesterOWLSyntaxClassExpressionParser;
+import org.semanticweb.owlapi.model.*;
+import org.semanticweb.owlapi.util.SimpleShortFormProvider;
+
+/**
+ * Tests template convenience methods.
+ *
+ * @author <a href="mailto:rctauber@gmail.com">Becky Tauber</a>
+ */
+public class TemplateHelperTest extends CoreTest {
+
+  private QuotedEntityChecker checker;
+  private static final OWLDataFactory dataFactory = OWLManager.getOWLDataFactory();
+
+  /**
+   * Set up the checker loaded with the input ontology
+   *
+   * @throws IOException on issue loading ontology
+   */
+  @Before
+  public void setUp() throws IOException {
+    OWLOntology inputOntology = loadOntology("/uberon.owl");
+
+    checker = new QuotedEntityChecker();
+    checker.setIOHelper(new IOHelper());
+    checker.addProvider(new SimpleShortFormProvider());
+    checker.addProperty(dataFactory.getRDFSLabel());
+    if (inputOntology != null) {
+      checker.addAll(inputOntology);
+    }
+  }
+
+  /**
+   * Tests getting a set of annotation properties from template values.
+   *
+   * @throws Exception on issue getting annotation properties
+   */
+  @Test
+  public void testGetAnnotationProperties() throws Exception {
+    String value = "oboInOwl:inSubset|oboInOwl:id";
+    String split = "|";
+    Set<OWLAnnotationProperty> properties =
+        TemplateHelper.getAnnotationProperties(checker, value, split);
+
+    OWLAnnotationProperty p1 = checker.getOWLAnnotationProperty("oboInOwl:inSubset");
+    OWLAnnotationProperty p2 = checker.getOWLAnnotationProperty("oboInOwl:id");
+    Set<OWLAnnotationProperty> propMatch = Sets.newHashSet(p1, p2);
+
+    assertEquals(propMatch, properties);
+  }
+
+  /**
+   * Tests getting various types of OWLAnnotations from strings.
+   *
+   * @throws Exception on issue getting annotations
+   */
+  @Test
+  public void testGetAnnotations() throws Exception {
+    // String
+    String template = "A rdfs:label";
+    String value = "anatomical cluster";
+    Set<OWLAnnotation> annotations =
+        TemplateHelper.getAnnotations("", checker, template, value, 0, 0);
+
+    OWLAnnotationProperty p = checker.getOWLAnnotationProperty("rdfs:label");
+    OWLLiteral lit = dataFactory.getOWLLiteral(value);
+    OWLAnnotation annMatch = dataFactory.getOWLAnnotation(p, lit);
+
+    for (OWLAnnotation a : annotations) {
+      assertEquals(annMatch, a);
+    }
+
+    // Language
+    template = "AL rdfs:label@en";
+    annotations = TemplateHelper.getAnnotations("", checker, template, value, 0, 0);
+
+    lit = dataFactory.getOWLLiteral(value, "en");
+    annMatch = dataFactory.getOWLAnnotation(p, lit);
+
+    for (OWLAnnotation a : annotations) {
+      assertEquals(annMatch, a);
+    }
+
+    // Typed
+    template = "AT rdfs:label^^xsd:string";
+    annotations = TemplateHelper.getAnnotations("", checker, template, value, 0, 0);
+
+    OWLDatatype dt = checker.getOWLDatatype("xsd:string");
+    lit = dataFactory.getOWLLiteral(value, dt);
+    annMatch = dataFactory.getOWLAnnotation(p, lit);
+
+    for (OWLAnnotation a : annotations) {
+      assertEquals(annMatch, a);
+    }
+
+    // IRI
+    template = "AI rdfs:seeAlso";
+    value = "http://robot.obolibrary.org/";
+    annotations = TemplateHelper.getAnnotations("", checker, template, value, 0, 0);
+
+    p = checker.getOWLAnnotationProperty("rdfs:seeAlso");
+    IRI iri = IRI.create(value);
+    annMatch = dataFactory.getOWLAnnotation(p, iri);
+
+    for (OWLAnnotation a : annotations) {
+      assertEquals(annMatch, a);
+    }
+  }
+
+  /** Tests getting a class expression from a template string and value. */
+  @Test
+  public void testGetClassExpressions() throws Exception {
+    ManchesterOWLSyntaxClassExpressionParser parser =
+        new ManchesterOWLSyntaxClassExpressionParser(dataFactory, checker);
+
+    String template = "C part_of some %";
+    String value = "obo:UBERON_0000467";
+    Set<OWLClassExpression> expressions =
+        TemplateHelper.getClassExpressions("", parser, template, value, 0, 0);
+    OWLObjectProperty p = checker.getOWLObjectProperty("part_of");
+    if (p == null) {
+      fail("'part_of' property not found by checker");
+    }
+    OWLClass c = checker.getOWLClass("anatomical system");
+    if (c == null) {
+      fail("'anatomical system' class not found by checker");
+    }
+    OWLClassExpression exprMatch = dataFactory.getOWLObjectSomeValuesFrom(p, c);
+
+    if (expressions.size() != 1) {
+      fail(String.format("Expected exactly 1 expression, got %d", expressions.size()));
+    }
+    for (OWLClassExpression expr : expressions) {
+      assertEquals(exprMatch.toString(), expr.toString());
+    }
+  }
+
+  /** Tests getting a data property expression from a template string and value. */
+  @Test
+  public void testGetDataPropertyExpressions() throws Exception {
+    String template = "P %";
+    String value = "UBERON:8888888";
+    Set<OWLDataPropertyExpression> expressions =
+        TemplateHelper.getDataPropertyExpressions("", checker, template, value, 0, 0);
+
+    OWLDataProperty p = checker.getOWLDataProperty("height");
+    if (p == null) {
+      fail("'height' property not found by checker");
+    }
+
+    if (expressions.size() != 1) {
+      fail(String.format("Expected exactly 1 expression, got %d", expressions.size()));
+    }
+    for (OWLDataPropertyExpression expr : expressions) {
+      assertEquals(p.toString(), expr.toString());
+    }
+  }
+
+  /**
+   * Tests getting a set of datatypes from template values.
+   *
+   * @throws Exception on issue getting datatypes
+   */
+  @Test
+  public void testGetDatatypes() throws Exception {
+    String value = "xsd:string|xsd:boolean";
+    String split = "|";
+    Set<OWLDatatype> datatypes = TemplateHelper.getDatatypes("", checker, value, split, 0, 0);
+
+    OWLDatatype dt1 = checker.getOWLDatatype("xsd:string");
+    OWLDatatype dt2 = checker.getOWLDatatype("xsd:boolean");
+    Set<OWLDatatype> dtMatch = Sets.newHashSet(dt1, dt2);
+
+    assertEquals(dtMatch, datatypes);
+  }
+
+  /** Tests getting an object property expression from a template string and value. */
+  @Test
+  public void testGetObjectPropertyExpressions() throws Exception {
+    String template = "P inverse %";
+    String value = "obo:BFO_0000050";
+    Set<OWLObjectPropertyExpression> expressions =
+        TemplateHelper.getObjectPropertyExpressions("", checker, template, value, 0, 0);
+
+    OWLObjectProperty p = checker.getOWLObjectProperty("part_of");
+    if (p == null) {
+      fail("'part_of' property not found by checker");
+    }
+    OWLObjectPropertyExpression exprMatch = dataFactory.getOWLObjectInverseOf(p);
+
+    if (expressions.size() != 1) {
+      fail(String.format("Expected exactly 1 expression, got %d", expressions.size()));
+    }
+    for (OWLObjectPropertyExpression expr : expressions) {
+      assertEquals(exprMatch.toString(), expr.toString());
+    }
+  }
+
+  /**
+   * Test annotation templates.
+   *
+   * @throws Exception if annotation properties cannot be found
+   */
+  @Test
+  public void testTemplateStrings() throws Exception {
+    Set<OWLAnnotation> anns;
+    OWLAnnotation ann = null;
+    QuotedEntityChecker checker = new QuotedEntityChecker();
+    checker.setIOHelper(new IOHelper());
+
+    anns = TemplateHelper.getStringAnnotations(checker, "A rdfs:label", null, "bar");
+    for (OWLAnnotation a : anns) {
+      ann = a;
+    }
+    assertEquals("Annotation(rdfs:label \"bar\"^^xsd:string)", ann.toString());
+
+    anns =
+        TemplateHelper.getTypedAnnotations(
+            "", checker, "AT rdfs:label^^xsd:integer", null, "1", 0, 0);
+    for (OWLAnnotation a : anns) {
+      ann = a;
+    }
+    assertEquals("Annotation(rdfs:label \"1\"^^xsd:integer)", ann.toString());
+
+    anns = TemplateHelper.getLanguageAnnotations(checker, "AL rdfs:label@en", null, "bar");
+    for (OWLAnnotation a : anns) {
+      ann = a;
+    }
+    assertEquals("Annotation(rdfs:label \"bar\"@en)", ann.toString());
+
+    ann = TemplateHelper.getIRIAnnotation(checker, "AI rdfs:label", IRI.create("http://bar.com"));
+    assertEquals("Annotation(rdfs:label <http://bar.com>)", ann.toString());
+  }
+}
diff --git a/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java b/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
deleted file mode 100644
index 1bed72ba..00000000
--- a/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package org.obolibrary.robot;
-
-import static org.junit.Assert.assertEquals;
-
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import org.junit.Test;
-import org.semanticweb.owlapi.manchestersyntax.parser.ManchesterOWLSyntaxClassExpressionParser;
-import org.semanticweb.owlapi.model.IRI;
-import org.semanticweb.owlapi.model.OWLAnnotation;
-import org.semanticweb.owlapi.model.OWLClass;
-import org.semanticweb.owlapi.model.OWLOntology;
-
-/** Tests for TemplateOperation. */
-public class TemplateOperationTest extends CoreTest {
-  /** Test wrapping and escaping names. */
-  @Test
-  public void testEscaping() {
-    assertEquals("testone", QuotedEntityChecker.wrap("testone"));
-    assertEquals("'test one'", QuotedEntityChecker.wrap("test one"));
-    assertEquals("5-bromo-2\\'-deoxyuridine", QuotedEntityChecker.wrap("5-bromo-2'-deoxyuridine"));
-  }
-
-  /**
-   * Test annotation templates.
-   *
-   * @throws Exception if annotation properties cannot be found
-   */
-  @Test
-  public void testTemplateStrings() throws Exception {
-    OWLAnnotation ann;
-    QuotedEntityChecker checker = new QuotedEntityChecker();
-    checker.setIOHelper(new IOHelper());
-
-    ann = TemplateHelper.getStringAnnotation(checker, "A rdfs:label", "bar");
-    assertEquals("Annotation(rdfs:label \"bar\"^^xsd:string)", ann.toString());
-
-    ann = TemplateHelper.getTypedAnnotation(checker, "AT rdfs:label^^xsd:integer", "1");
-    assertEquals("Annotation(rdfs:label \"1\"^^xsd:integer)", ann.toString());
-
-    ann = TemplateHelper.getLanguageAnnotation(checker, "AL rdfs:label@en", "bar");
-    assertEquals("Annotation(rdfs:label \"bar\"@en)", ann.toString());
-
-    ann = TemplateHelper.getIRIAnnotation(checker, "AI rdfs:label", IRI.create("http://bar.com"));
-    assertEquals("Annotation(rdfs:label <http://bar.com>)", ann.toString());
-  }
-
-  /**
-   * Test the QuotedEntityChecker.
-   *
-   * @throws Exception if entities cannot be found
-   */
-  @Test
-  public void testChecker() throws Exception {
-    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
-    QuotedEntityChecker checker = new QuotedEntityChecker();
-    checker.addProperty(dataFactory.getRDFSLabel());
-    checker.addAll(simpleParts);
-
-    // Test getting classes by label without IOHelper
-    IRI iri = IRI.create(base + "simple.owl#test1");
-    OWLClass cls = dataFactory.getOWLClass(iri);
-    assertEquals(cls, checker.getOWLClass("test one"));
-    assertEquals(cls, checker.getOWLClass("'test one'"));
-    assertEquals(cls, checker.getOWLClass("Test 1"));
-
-    // Test getting IRI by label
-    assertEquals(checker.getIRI("test one", false), cls.getIRI());
-
-    // Test getting class by label with IOHelper
-    IOHelper ioHelper = new IOHelper();
-    iri = ioHelper.createIRI("GO:XXXX");
-    cls = dataFactory.getOWLClass(iri);
-    checker.setIOHelper(ioHelper);
-    assertEquals(cls, checker.getOWLClass("GO:XXXX"));
-
-    System.out.println("PARSER");
-    ManchesterOWLSyntaxClassExpressionParser parser =
-        new ManchesterOWLSyntaxClassExpressionParser(
-            dataFactory, checker
-            // new org.semanticweb.owlapi.expression.ShortFormEntityChecker(
-            //    new org.semanticweb.owlapi.util.
-            //        BidirectionalShortFormProviderAdapter(
-            //            ioHelper.getPrefixManager()))
-            );
-    // assertEquals(cls, parser.parse("'test one'"));
-    assertEquals(cls, parser.parse("GO:XXXX"));
-    // checker.add(cls, "%");
-    // assertEquals("", parser.parse("%"));
-  }
-
-  /**
-   * Test templating.
-   *
-   * @throws Exception if entities cannot be found
-   */
-  @Test
-  public void testTemplateCSV() throws Exception {
-    Map<String, List<List<String>>> tables = new LinkedHashMap<>();
-    String path = "/template.csv";
-    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
-    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
-
-    QuotedEntityChecker checker = new QuotedEntityChecker();
-    checker.addProperty(dataFactory.getRDFSLabel());
-    checker.addAll(simpleParts);
-
-    OWLOntology template = TemplateOperation.template(tables, simpleParts);
-    OntologyHelper.setOntologyIRI(template, IRI.create("http://test.com/template.owl"), null);
-    assertIdentical("/template.owl", template);
-  }
-
-  /**
-   * Test multiple templates.
-   *
-   * @throws Exception if entities cannot be found
-   */
-  @Test
-  public void testTemplates() throws Exception {
-    Map<String, List<List<String>>> tables = new LinkedHashMap<String, List<List<String>>>();
-    String path = "/template-ids.csv";
-    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
-    path = "/template-labels.csv";
-    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
-    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
-    OWLOntology template = TemplateOperation.template(tables, simpleParts);
-    assertEquals("Count classes", 4, template.getClassesInSignature().size());
-    assertEquals("Count logical axioms", 4, template.getLogicalAxiomCount());
-    assertEquals("Count all axioms", 10, template.getAxiomCount());
-  }
-}
diff --git a/robot-core/src/test/java/org/obolibrary/robot/TemplateTest.java b/robot-core/src/test/java/org/obolibrary/robot/TemplateTest.java
new file mode 100644
index 00000000..e3e9ad9d
--- /dev/null
+++ b/robot-core/src/test/java/org/obolibrary/robot/TemplateTest.java
@@ -0,0 +1,133 @@
+package org.obolibrary.robot;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.*;
+import org.junit.Test;
+import org.semanticweb.owlapi.model.IRI;
+import org.semanticweb.owlapi.model.OWLAxiom;
+import org.semanticweb.owlapi.model.OWLOntology;
+
+/**
+ * Tests Template class and class methods.
+ *
+ * @author <a href="mailto:rctauber@gmail.com">Becky Tauber</a>
+ */
+public class TemplateTest extends CoreTest {
+
+  /**
+   * Test legacy templating.
+   *
+   * @throws Exception if entities cannot be found
+   */
+  @Test
+  public void testTemplateCSV() throws Exception {
+    String path = "/template.csv";
+    List<List<String>> rows = TemplateHelper.readCSV(this.getClass().getResourceAsStream(path));
+    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
+
+    Template t = new Template(path, rows, simpleParts);
+    OWLOntology template = t.generateOutputOntology("http://test.com/template.owl", false);
+    assertIdentical("/template.owl", template);
+  }
+
+  /**
+   * Test legacy templating.
+   *
+   * @throws Exception if entities cannot be found
+   */
+  @Test
+  public void testLegacyTemplateCSV() throws Exception {
+    String path = "/legacy-template.csv";
+    List<List<String>> rows = TemplateHelper.readCSV(this.getClass().getResourceAsStream(path));
+    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
+
+    Template t = new Template(path, rows, simpleParts);
+    OWLOntology template = t.generateOutputOntology("http://test.com/template.owl", false);
+    assertIdentical("/template.owl", template);
+  }
+
+  /**
+   * Test multiple templates.
+   *
+   * @throws Exception if entities cannot be found
+   */
+  @Test
+  public void testTemplates() throws Exception {
+    Map<String, List<List<String>>> tables = new LinkedHashMap<>();
+    String path = "/template-ids.csv";
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    path = "/template-labels.csv";
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    path = "/template.csv";
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
+
+    List<OWLOntology> ontologies = new ArrayList<>();
+    for (String table : tables.keySet()) {
+      Template t = new Template(table, tables.get(table), simpleParts);
+      ontologies.add(t.generateOutputOntology());
+    }
+
+    OWLOntology template = MergeOperation.merge(ontologies);
+    for (OWLAxiom cls : template.getAxioms()) {
+      System.out.println(cls);
+    }
+    assertEquals("Count classes", 4, template.getClassesInSignature().size());
+    assertEquals("Count logical axioms", 3, template.getLogicalAxiomCount());
+    assertEquals("Count all axioms", 11, template.getAxiomCount());
+  }
+
+  /**
+   * Test multiple templates in legacy format.
+   *
+   * @throws Exception if entities cannot be found
+   */
+  @Test
+  public void testLegacyTemplates() throws Exception {
+    Map<String, List<List<String>>> tables = new LinkedHashMap<>();
+    String path = "/template-ids.csv";
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    path = "/legacy-template-labels.csv";
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    path = "/legacy-template.csv";
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    OWLOntology simpleParts = loadOntology("/simple_parts.owl");
+
+    List<OWLOntology> ontologies = new ArrayList<>();
+    for (String table : tables.keySet()) {
+      Template t = new Template(table, tables.get(table), simpleParts);
+      ontologies.add(t.generateOutputOntology());
+    }
+
+    OWLOntology template = MergeOperation.merge(ontologies);
+    for (OWLAxiom cls : template.getAxioms()) {
+      System.out.println(cls);
+    }
+    assertEquals("Count classes", 4, template.getClassesInSignature().size());
+    assertEquals("Count logical axioms", 3, template.getLogicalAxiomCount());
+    assertEquals("Count all axioms", 11, template.getAxiomCount());
+  }
+
+  /**
+   * Test legacy doc template example.
+   *
+   * @throws Exception on any issue
+   */
+  @Test
+  public void testLegacyTemplate() throws Exception {
+    Map<String, String> options = TemplateOperation.getDefaultOptions();
+    IOHelper ioHelper = new IOHelper();
+    ioHelper.addPrefix("ex", "http://example.com/");
+
+    Map<String, List<List<String>>> tables = new LinkedHashMap<>();
+    String path = "/docs-template.csv";
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
+
+    OWLOntology template = TemplateOperation.template(null, ioHelper, tables, options);
+
+    OntologyHelper.setOntologyIRI(
+        template, IRI.create("https://github.com/ontodev/robot/examples/template.owl"), null);
+    assertIdentical("/docs-template.owl", template);
+  }
+}
diff --git a/robot-core/src/test/resources/docs-template.csv b/robot-core/src/test/resources/docs-template.csv
new file mode 100644
index 00000000..d2398b21
--- /dev/null
+++ b/robot-core/src/test/resources/docs-template.csv
@@ -0,0 +1,5 @@
+ID,Label,Definition,Definition Source,See Also,Editor,RDF Type,Class Type,Parent IRI
+ID,A rdfs:label,A IAO:0000115,A IAO:0000119,AI rdfs:seeAlso,A IAO:0000117,TYPE,CLASS_TYPE,CI
+ex:F344N,F 344/N,An inbred strain of rat used in many scientific investigations.,James A. Overton,http://www.informatics.jax.org/external/festing/rat/docs/F344.shtml,James A. Overton,owl:Class,subclass,NCBITaxon:10116
+ex:B6C3F1,B6C3F1,An inbred strain of mouse used in many scientific investigations.,James A. Overton,http://jaxmice.jax.org/strain/100010.html,James A. Overton,owl:Class,subclass,NCBITaxon:10090
+ex:rat-1234,rat 1234,,,F 344/N,,ex:F344N,,
\ No newline at end of file
diff --git a/robot-core/src/test/resources/docs-template.owl b/robot-core/src/test/resources/docs-template.owl
new file mode 100644
index 00000000..edb5f48e
--- /dev/null
+++ b/robot-core/src/test/resources/docs-template.owl
@@ -0,0 +1,74 @@
+<?xml version="1.0"?>
+<rdf:RDF xmlns="https://github.com/ontodev/robot/examples/template.owl#"
+     xml:base="https://github.com/ontodev/robot/examples/template.owl"
+     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:owl="http://www.w3.org/2002/07/owl#"
+     xmlns:xml="http://www.w3.org/XML/1998/namespace"
+     xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
+     xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
+     xmlns:obo="http://purl.obolibrary.org/obo/">
+    <owl:Ontology rdf:about="https://github.com/ontodev/robot/examples/template.owl"/>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Classes
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://example.com/B6C3F1 -->
+
+    <owl:Class rdf:about="http://example.com/B6C3F1">
+        <rdfs:subClassOf rdf:resource="http://purl.obolibrary.org/obo/NCBITaxon_10090"/>
+        <obo:IAO_0000115 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">An inbred strain of mouse used in many scientific investigations.</obo:IAO_0000115>
+        <obo:IAO_0000117 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000117>
+        <obo:IAO_0000119 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000119>
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">B6C3F1</rdfs:label>
+        <rdfs:seeAlso rdf:resource="http://jaxmice.jax.org/strain/100010.html"/>
+    </owl:Class>
+    
+
+
+    <!-- http://example.com/F344N -->
+
+    <owl:Class rdf:about="http://example.com/F344N">
+        <rdfs:subClassOf rdf:resource="http://purl.obolibrary.org/obo/NCBITaxon_10116"/>
+        <obo:IAO_0000115 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">An inbred strain of rat used in many scientific investigations.</obo:IAO_0000115>
+        <obo:IAO_0000117 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000117>
+        <obo:IAO_0000119 rdf:datatype="http://www.w3.org/2001/XMLSchema#string">James A. Overton</obo:IAO_0000119>
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">F 344/N</rdfs:label>
+        <rdfs:seeAlso rdf:resource="http://www.informatics.jax.org/external/festing/rat/docs/F344.shtml"/>
+    </owl:Class>
+    
+
+
+    <!-- 
+    ///////////////////////////////////////////////////////////////////////////////////////
+    //
+    // Individuals
+    //
+    ///////////////////////////////////////////////////////////////////////////////////////
+     -->
+
+    
+
+
+    <!-- http://example.com/rat-1234 -->
+
+    <owl:NamedIndividual rdf:about="http://example.com/rat-1234">
+        <rdf:type rdf:resource="http://example.com/F344N"/>
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">rat 1234</rdfs:label>
+        <rdfs:seeAlso rdf:resource="http://example.com/F344N"/>
+    </owl:NamedIndividual>
+</rdf:RDF>
+
+
+
+<!-- Generated by the OWL API (version 4.2.8.20170104-2310) https://github.com/owlcs/owlapi -->
+
diff --git a/robot-core/src/test/resources/legacy-template-labels.csv b/robot-core/src/test/resources/legacy-template-labels.csv
new file mode 100644
index 00000000..898e4849
--- /dev/null
+++ b/robot-core/src/test/resources/legacy-template-labels.csv
@@ -0,0 +1,4 @@
+Label,Synonyms,Type,Parent,Parts
+LABEL,A IAO:0000118 SPLIT=|,CLASS_TYPE,C % SPLIT=|,C part_of some %
+test 3,synonym 1|synonym 2,subclass,test one|test2,test one
+test 4,,equivalent,test one,(test2 and 'test 3')
diff --git a/robot-core/src/test/resources/legacy-template.csv b/robot-core/src/test/resources/legacy-template.csv
new file mode 100644
index 00000000..2ea0c73b
--- /dev/null
+++ b/robot-core/src/test/resources/legacy-template.csv
@@ -0,0 +1,5 @@
+Label,Label Comment,ID,Synonyms,Type,Parent,Parts,Parts Annotation
+A rdfs:label,>AL rdfs:comment@en,ID,A IAO:0000118 SPLIT=|,CLASS_TYPE,C %,C part_of some %,>C rdfs:comment
+skip this row
+test 3,test 3 comment,GO:1234,synonym 1|synonym 2,subclass,test2,test one,test one comment
+test 4,test 4 comment,GO:1235,,equivalent,test one,(test2 and 'test 3'),test2 and test 3 comment
diff --git a/robot-core/src/test/resources/template-labels.csv b/robot-core/src/test/resources/template-labels.csv
index 898e4849..564bd734 100644
--- a/robot-core/src/test/resources/template-labels.csv
+++ b/robot-core/src/test/resources/template-labels.csv
@@ -1,4 +1,4 @@
-Label,Synonyms,Type,Parent,Parts
-LABEL,A IAO:0000118 SPLIT=|,CLASS_TYPE,C % SPLIT=|,C part_of some %
-test 3,synonym 1|synonym 2,subclass,test one|test2,test one
-test 4,,equivalent,test one,(test2 and 'test 3')
+Label,Synonyms,Parent,Subclass Parts,Equivalent,Equivalent Parts
+LABEL,A IAO:0000118 SPLIT=|,SC % SPLIT=|,SC part_of some %,EC %,EC part_of some %
+test 3,synonym 1|synonym 2,test one|test2,test one,,
+test 4,,,,test one,(test2 and 'test 3')
diff --git a/robot-core/src/test/resources/template.csv b/robot-core/src/test/resources/template.csv
index 2ea0c73b..1f5fbfe6 100644
--- a/robot-core/src/test/resources/template.csv
+++ b/robot-core/src/test/resources/template.csv
@@ -1,5 +1,5 @@
-Label,Label Comment,ID,Synonyms,Type,Parent,Parts,Parts Annotation
-A rdfs:label,>AL rdfs:comment@en,ID,A IAO:0000118 SPLIT=|,CLASS_TYPE,C %,C part_of some %,>C rdfs:comment
+Label,Label Comment,ID,Synonyms,Parent,Parts,Parts Annotation,Equivalent,Eq Annotation
+A rdfs:label,>AL rdfs:comment@en,ID,A IAO:0000118 SPLIT=|,SC %,SC part_of some %,>A rdfs:comment,EC %,>A rdfs:comment
 skip this row
-test 3,test 3 comment,GO:1234,synonym 1|synonym 2,subclass,test2,test one,test one comment
-test 4,test 4 comment,GO:1235,,equivalent,test one,(test2 and 'test 3'),test2 and test 3 comment
+test 3,test 3 comment,GO:1234,synonym 1|synonym 2,test2,test one,test one comment,,
+test 4,test 4 comment,GO:1235,,,,,'test one' and (part_of some (test2 and 'test 3')),test2 and test 3 comment
diff --git a/robot-core/src/test/resources/template.owl b/robot-core/src/test/resources/template.owl
index 5b0866f0..5b79fa17 100644
--- a/robot-core/src/test/resources/template.owl
+++ b/robot-core/src/test/resources/template.owl
@@ -8,10 +8,10 @@
      xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
      xmlns:obo="http://purl.obolibrary.org/obo/">
     <owl:Ontology rdf:about="http://test.com/template.owl"/>
-    
 
 
-    <!-- 
+
+    <!--
     ///////////////////////////////////////////////////////////////////////////////////////
     //
     // Classes
@@ -19,7 +19,7 @@
     ///////////////////////////////////////////////////////////////////////////////////////
      -->
 
-    
+
 
 
     <!-- http://purl.obolibrary.org/obo/GO_1234 -->
@@ -53,7 +53,7 @@
         <owl:annotatedTarget rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test 3</owl:annotatedTarget>
         <rdfs:comment xml:lang="en">test 3 comment</rdfs:comment>
     </owl:Axiom>
-    
+
 
 
     <!-- http://purl.obolibrary.org/obo/GO_1235 -->
@@ -113,4 +113,3 @@
 
 
 <!-- Generated by the OWL API (version 4.2.6.20160910-2108) https://github.com/owlcs/owlapi -->
-