diff --git a/.vale/styles/spelling-exceptions.txt b/.vale/styles/spelling-exceptions.txt
index d018438b..904fc251 100644
--- a/.vale/styles/spelling-exceptions.txt
+++ b/.vale/styles/spelling-exceptions.txt
@@ -85,6 +85,7 @@ namespace
 namespaces
 Nautobot
 Netbox
+Netutils
 Newsfragment
 Nornir
 npm
diff --git a/changelog/+5660f1dc.added.md b/changelog/+5660f1dc.added.md
new file mode 100644
index 00000000..0d51a3d9
--- /dev/null
+++ b/changelog/+5660f1dc.added.md
@@ -0,0 +1 @@
+Refactored management of Jinja2 templating to allow for filters within Infrahub. Builtin filters as well as those from Netutils are available.
diff --git a/docs/_templates/sdk_template_reference.j2 b/docs/_templates/sdk_template_reference.j2
new file mode 100644
index 00000000..dcd59d78
--- /dev/null
+++ b/docs/_templates/sdk_template_reference.j2
@@ -0,0 +1,27 @@
+---
+title: Python SDK Templating
+---
+Filters can be used when defining [computed attributes](https://docs.infrahub.app/guides/computed-attributes) or [Jinja2 Transforms](https://docs.infrahub.app/guides/jinja2-transform) within Infrahub.
+
+## Builtin Jinja2 filters
+
+The following filters are those that are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. The trusted column indicates if the filter is allowed for use with Infrahub's computed attributes when the server is configured in strict mode.
+
+
+| Name | Trusted |
+|----------|----------|
+{% for filter in builtin %}
+| {{ filter.name }} | {% if filter.trusted %}✅{% else %}❌{% endif %} |
+{% endfor %}
+
+
+## Netutils filters
+
+The following Jinja2 filters from Netutils are included within Infrahub.
+
+| Name | Trusted |
+|----------|----------|
+{% for filter in netutils %}
+| {{ filter.name }} | {% if filter.trusted %}✅{% else %}❌{% endif %} |
+{% endfor %}
+
diff --git a/docs/docs/python-sdk/reference/templating.mdx b/docs/docs/python-sdk/reference/templating.mdx
new file mode 100644
index 00000000..62f1b8aa
--- /dev/null
+++ b/docs/docs/python-sdk/reference/templating.mdx
@@ -0,0 +1,153 @@
+---
+title: Python SDK Templating
+---
+Filters can be used when defining [computed attributes](https://docs.infrahub.app/guides/computed-attributes) or [Jinja2 Transforms](https://docs.infrahub.app/guides/jinja2-transform) within Infrahub.
+
+## Builtin Jinja2 filters
+
+The following filters are those that are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. The trusted column indicates if the filter is allowed for use with Infrahub's computed attributes when the server is configured in strict mode.
+
+
+| Name | Trusted |
+|----------|----------|
+| abs | ✅ |
+| attr | ❌ |
+| batch | ❌ |
+| capitalize | ✅ |
+| center | ✅ |
+| count | ✅ |
+| d | ✅ |
+| default | ✅ |
+| dictsort | ❌ |
+| e | ✅ |
+| escape | ✅ |
+| filesizeformat | ✅ |
+| first | ✅ |
+| float | ✅ |
+| forceescape | ✅ |
+| format | ✅ |
+| groupby | ❌ |
+| indent | ✅ |
+| int | ✅ |
+| items | ❌ |
+| join | ✅ |
+| last | ✅ |
+| length | ✅ |
+| list | ✅ |
+| lower | ✅ |
+| map | ❌ |
+| max | ✅ |
+| min | ✅ |
+| pprint | ❌ |
+| random | ❌ |
+| reject | ❌ |
+| rejectattr | ❌ |
+| replace | ✅ |
+| reverse | ✅ |
+| round | ✅ |
+| safe | ❌ |
+| select | ❌ |
+| selectattr | ❌ |
+| slice | ✅ |
+| sort | ❌ |
+| string | ✅ |
+| striptags | ✅ |
+| sum | ✅ |
+| title | ✅ |
+| tojson | ❌ |
+| trim | ✅ |
+| truncate | ✅ |
+| unique | ❌ |
+| upper | ✅ |
+| urlencode | ✅ |
+| urlize | ❌ |
+| wordcount | ✅ |
+| wordwrap | ✅ |
+| xmlattr | ❌ |
+
+
+## Netutils filters
+
+The following Jinja2 filters from Netutils are included within Infrahub.
+
+| Name | Trusted |
+|----------|----------|
+| abbreviated_interface_name | ✅ |
+| abbreviated_interface_name_list | ✅ |
+| asn_to_int | ✅ |
+| bits_to_name | ✅ |
+| bytes_to_name | ✅ |
+| canonical_interface_name | ✅ |
+| canonical_interface_name_list | ✅ |
+| cidr_to_netmask | ✅ |
+| cidr_to_netmaskv6 | ✅ |
+| clean_config | ✅ |
+| compare_version_loose | ✅ |
+| compare_version_strict | ✅ |
+| config_compliance | ✅ |
+| config_section_not_parsed | ✅ |
+| delimiter_change | ✅ |
+| diff_network_config | ✅ |
+| feature_compliance | ✅ |
+| find_unordered_cfg_lines | ✅ |
+| fqdn_to_ip | ❌ |
+| get_all_host | ❌ |
+| get_broadcast_address | ✅ |
+| get_first_usable | ✅ |
+| get_ips_sorted | ✅ |
+| get_nist_urls | ✅ |
+| get_nist_vendor_platform_urls | ✅ |
+| get_oui | ✅ |
+| get_peer_ip | ✅ |
+| get_range_ips | ✅ |
+| get_upgrade_path | ✅ |
+| get_usable_range | ✅ |
+| hash_data | ✅ |
+| int_to_asdot | ✅ |
+| interface_range_compress | ✅ |
+| interface_range_expansion | ✅ |
+| ip_addition | ✅ |
+| ip_subtract | ✅ |
+| ip_to_bin | ✅ |
+| ip_to_hex | ✅ |
+| ipaddress_address | ✅ |
+| ipaddress_interface | ✅ |
+| ipaddress_network | ✅ |
+| is_classful | ✅ |
+| is_fqdn_resolvable | ❌ |
+| is_ip | ✅ |
+| is_ip_range | ✅ |
+| is_ip_within | ✅ |
+| is_netmask | ✅ |
+| is_network | ✅ |
+| is_reversible_wildcardmask | ✅ |
+| is_valid_mac | ✅ |
+| longest_prefix_match | ✅ |
+| mac_normalize | ✅ |
+| mac_to_format | ✅ |
+| mac_to_int | ✅ |
+| mac_type | ✅ |
+| name_to_bits | ✅ |
+| name_to_bytes | ✅ |
+| name_to_name | ✅ |
+| netmask_to_cidr | ✅ |
+| netmask_to_wildcardmask | ✅ |
+| normalise_delimiter_caret_c | ✅ |
+| paloalto_panos_brace_to_set | ✅ |
+| paloalto_panos_clean_newlines | ✅ |
+| regex_findall | ❌ |
+| regex_match | ❌ |
+| regex_search | ❌ |
+| regex_split | ❌ |
+| regex_sub | ❌ |
+| sanitize_config | ✅ |
+| section_config | ✅ |
+| sort_interface_list | ✅ |
+| split_interface | ✅ |
+| uptime_seconds_to_string | ✅ |
+| uptime_string_to_seconds | ✅ |
+| version_metadata | ✅ |
+| vlanconfig_to_list | ✅ |
+| vlanlist_to_config | ✅ |
+| wildcardmask_to_netmask | ✅ |
+
\ No newline at end of file
diff --git a/docs/sidebars-python-sdk.ts b/docs/sidebars-python-sdk.ts
index 8da5a81b..7cde4058 100644
--- a/docs/sidebars-python-sdk.ts
+++ b/docs/sidebars-python-sdk.ts
@@ -38,6 +38,7 @@ const sidebars: SidebarsConfig = {
           label: 'Reference',
           items: [
             'reference/config',
+            'reference/templating',
           ],
         },
       ],
diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py
index 633ccd2c..0d9a850f 100644
--- a/infrahub_sdk/ctl/cli_commands.py
+++ b/infrahub_sdk/ctl/cli_commands.py
@@ -9,7 +9,6 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Callable, Optional
 
-import jinja2
 import typer
 import ujson
 from rich.console import Console
@@ -18,7 +17,6 @@
 from rich.panel import Panel
 from rich.pretty import Pretty
 from rich.table import Table
-from rich.traceback import Traceback
 
 from .. import __version__ as sdk_version
 from ..async_typer import AsyncTyper
@@ -31,7 +29,7 @@
 from ..ctl.generator import run as run_generator
 from ..ctl.menu import app as menu_app
 from ..ctl.object import app as object_app
-from ..ctl.render import list_jinja2_transforms
+from ..ctl.render import list_jinja2_transforms, print_template_errors
 from ..ctl.repository import app as repository_app
 from ..ctl.repository import get_repository_config
 from ..ctl.schema import app as schema_app
@@ -44,8 +42,9 @@
 )
 from ..ctl.validate import app as validate_app
 from ..exceptions import GraphQLError, ModuleImportError
-from ..jinja2 import identify_faulty_jinja_code
 from ..schema import MainSchemaTypesAll, SchemaRoot
+from ..template import Jinja2Template
+from ..template.exceptions import JinjaTemplateError
 from ..utils import get_branch, write_to_file
 from ..yaml import SchemaFile
 from .exporter import dump
@@ -168,43 +167,28 @@ async def run(
         raise typer.Abort(f"Unable to Load the method {method} in the Python script at {script}")
 
     client = initialize_client(
-        branch=branch, timeout=timeout, max_concurrent_execution=concurrent, identifier=module_name
+        branch=branch,
+        timeout=timeout,
+        max_concurrent_execution=concurrent,
+        identifier=module_name,
     )
     func = getattr(module, method)
     await func(client=client, log=log, branch=branch, **variables_dict)
 
 
-def render_jinja2_template(template_path: Path, variables: dict[str, str], data: dict[str, Any]) -> str:
-    if not template_path.is_file():
-        console.print(f"[red]Unable to locate the template at {template_path}")
-        raise typer.Exit(1)
-
-    templateLoader = jinja2.FileSystemLoader(searchpath=".")
-    templateEnv = jinja2.Environment(loader=templateLoader, trim_blocks=True, lstrip_blocks=True)
-    template = templateEnv.get_template(str(template_path))
-
+async def render_jinja2_template(template_path: Path, variables: dict[str, Any], data: dict[str, Any]) -> str:
+    variables["data"] = data
+    jinja_template = Jinja2Template(template=Path(template_path), template_directory=Path())
     try:
-        rendered_tpl = template.render(**variables, data=data)  # type: ignore[arg-type]
-    except jinja2.TemplateSyntaxError as exc:
-        console.print("[red]Syntax Error detected on the template")
-        console.print(f"[yellow]  {exc}")
-        raise typer.Exit(1) from exc
-
-    except jinja2.UndefinedError as exc:
-        console.print("[red]An error occurred while rendering the jinja template")
-        traceback = Traceback(show_locals=False)
-        errors = identify_faulty_jinja_code(traceback=traceback)
-        for frame, syntax in errors:
-            console.print(f"[yellow]{frame.filename} on line {frame.lineno}\n")
-            console.print(syntax)
-        console.print("")
-        console.print(traceback.trace.stacks[0].exc_value)
+        rendered_tpl = await jinja_template.render(variables=variables)
+    except JinjaTemplateError as exc:
+        print_template_errors(error=exc, console=console)
         raise typer.Exit(1) from exc
 
     return rendered_tpl
 
 
-def _run_transform(
+async def _run_transform(
     query_name: str,
     variables: dict[str, Any],
     transform_func: Callable,
@@ -227,7 +211,11 @@ def _run_transform(
 
     try:
         response = execute_graphql_query(
-            query=query_name, variables_dict=variables, branch=branch, debug=debug, repository_config=repository_config
+            query=query_name,
+            variables_dict=variables,
+            branch=branch,
+            debug=debug,
+            repository_config=repository_config,
         )
 
         # TODO: response is a dict and can't be printed to the console in this way.
@@ -249,7 +237,7 @@ def _run_transform(
         raise typer.Abort()
 
     if asyncio.iscoroutinefunction(transform_func):
-        output = asyncio.run(transform_func(response))
+        output = await transform_func(response)
     else:
         output = transform_func(response)
     return output
@@ -257,7 +245,7 @@ def _run_transform(
 
 @app.command(name="render")
 @catch_exception(console=console)
-def render(
+async def render(
     transform_name: str = typer.Argument(default="", help="Name of the Python transformation", show_default=False),
     variables: Optional[list[str]] = typer.Argument(
         None, help="Variables to pass along with the query. Format key=value key=value."
@@ -289,7 +277,7 @@ def render(
     transform_func = functools.partial(render_jinja2_template, transform_config.template_path, variables_dict)
 
     # Query GQL and run the transform
-    result = _run_transform(
+    result = await _run_transform(
         query_name=transform_config.query,
         variables=variables_dict,
         transform_func=transform_func,
@@ -410,7 +398,10 @@ def version() -> None:
 
 @app.command(name="info")
 @catch_exception(console=console)
-def info(detail: bool = typer.Option(False, help="Display detailed information."), _: str = CONFIG_PARAM) -> None:  # noqa: PLR0915
+def info(  # noqa: PLR0915
+    detail: bool = typer.Option(False, help="Display detailed information."),
+    _: str = CONFIG_PARAM,
+) -> None:
     """Display the status of the Python SDK."""
 
     info: dict[str, Any] = {
@@ -476,10 +467,14 @@ def info(detail: bool = typer.Option(False, help="Display detailed information."
         infrahub_info = Table(show_header=False, box=None)
         if info["user_info"]:
             infrahub_info.add_row("User:", info["user_info"]["AccountProfile"]["display_label"])
-            infrahub_info.add_row("Description:", info["user_info"]["AccountProfile"]["description"]["value"])
+            infrahub_info.add_row(
+                "Description:",
+                info["user_info"]["AccountProfile"]["description"]["value"],
+            )
             infrahub_info.add_row("Status:", info["user_info"]["AccountProfile"]["status"]["label"])
             infrahub_info.add_row(
-                "Number of Groups:", str(info["user_info"]["AccountProfile"]["member_of_groups"]["count"])
+                "Number of Groups:",
+                str(info["user_info"]["AccountProfile"]["member_of_groups"]["count"]),
             )
 
             if groups := info["groups"]:
diff --git a/infrahub_sdk/ctl/render.py b/infrahub_sdk/ctl/render.py
index 05122102..cb1c962e 100644
--- a/infrahub_sdk/ctl/render.py
+++ b/infrahub_sdk/ctl/render.py
@@ -1,6 +1,12 @@
 from rich.console import Console
 
 from ..schema.repository import InfrahubRepositoryConfig
+from ..template.exceptions import (
+    JinjaTemplateError,
+    JinjaTemplateNotFoundError,
+    JinjaTemplateSyntaxError,
+    JinjaTemplateUndefinedError,
+)
 
 
 def list_jinja2_transforms(config: InfrahubRepositoryConfig) -> None:
@@ -9,3 +15,36 @@ def list_jinja2_transforms(config: InfrahubRepositoryConfig) -> None:
 
     for transform in config.jinja2_transforms:
         console.print(f"{transform.name} ({transform.template_path})")
+
+
+def print_template_errors(error: JinjaTemplateError, console: Console) -> None:
+    if isinstance(error, JinjaTemplateNotFoundError):
+        console.print("[red]An error occurred while rendering the jinja template")
+        console.print("")
+        if error.base_template:
+            console.print(f"Base template: [yellow]{error.base_template}")
+        console.print(f"Missing template: [yellow]{error.filename}")
+        return
+
+    if isinstance(error, JinjaTemplateUndefinedError):
+        console.print("[red]An error occurred while rendering the jinja template")
+        for current_error in error.errors:
+            console.print(f"[yellow]{current_error.frame.filename} on line {current_error.frame.lineno}\n")
+            console.print(current_error.syntax)
+        console.print("")
+        console.print(error.message)
+        return
+
+    if isinstance(error, JinjaTemplateSyntaxError):
+        console.print("[red]A syntax error was encountered within the template")
+        console.print("")
+        if error.filename:
+            console.print(f"Filename: [yellow]{error.filename}")
+        console.print(f"Line number: [yellow]{error.lineno}")
+        console.print()
+        console.print(error.message)
+        return
+
+    console.print("[red]An error occurred while rendering the jinja template")
+    console.print("")
+    console.print(f"[yellow]{error.message}")
diff --git a/infrahub_sdk/pytest_plugin/items/jinja2_transform.py b/infrahub_sdk/pytest_plugin/items/jinja2_transform.py
index a5bba094..4ed2e2c5 100644
--- a/infrahub_sdk/pytest_plugin/items/jinja2_transform.py
+++ b/infrahub_sdk/pytest_plugin/items/jinja2_transform.py
@@ -1,51 +1,47 @@
 from __future__ import annotations
 
+import asyncio
 import difflib
+from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
 import jinja2
 import ujson
 from httpx import HTTPStatusError
-from rich.console import Console
-from rich.traceback import Traceback
 
-from ...jinja2 import identify_faulty_jinja_code
-from ..exceptions import Jinja2TransformError, Jinja2TransformUndefinedError, OutputMatchError
+from ...template import Jinja2Template
+from ...template.exceptions import JinjaTemplateError
+from ..exceptions import OutputMatchError
 from ..models import InfrahubInputOutputTest, InfrahubTestExpectedResult
 from .base import InfrahubItem
 
 if TYPE_CHECKING:
-    from pathlib import Path
-
     from pytest import ExceptionInfo
 
 
 class InfrahubJinja2Item(InfrahubItem):
+    def _get_jinja2(self) -> Jinja2Template:
+        return Jinja2Template(
+            template=Path(self.resource_config.template_path),  # type: ignore[attr-defined]
+            template_directory=Path(self.session.infrahub_config_path.parent),  # type: ignore[attr-defined]
+        )
+
     def get_jinja2_environment(self) -> jinja2.Environment:
-        loader = jinja2.FileSystemLoader(self.session.infrahub_config_path.parent)  # type: ignore[attr-defined]
-        return jinja2.Environment(loader=loader, trim_blocks=True, lstrip_blocks=True)
+        jinja2_template = self._get_jinja2()
+        return jinja2_template.get_environment()
 
     def get_jinja2_template(self) -> jinja2.Template:
-        return self.get_jinja2_environment().get_template(str(self.resource_config.template_path))  # type: ignore[attr-defined]
+        jinja2_template = self._get_jinja2()
+        return jinja2_template.get_template()
 
     def render_jinja2_template(self, variables: dict[str, Any]) -> str | None:
+        jinja2_template = self._get_jinja2()
+
         try:
-            return self.get_jinja2_template().render(**variables)
-        except jinja2.UndefinedError as exc:
-            traceback = Traceback(show_locals=False)
-            errors = identify_faulty_jinja_code(traceback=traceback)
-            console = Console()
-            with console.capture() as capture:
-                console.print(f"An error occurred while rendering Jinja2 transform:{self.name!r}\n", soft_wrap=True)
-                console.print(f"{exc.message}\n", soft_wrap=True)
-                for frame, syntax in errors:
-                    console.print(f"{frame.filename} on line {frame.lineno}\n", soft_wrap=True)
-                    console.print(syntax, soft_wrap=True)
-            str_output = capture.get()
+            return asyncio.run(jinja2_template.render(variables=variables))
+        except JinjaTemplateError as exc:
             if self.test.expect == InfrahubTestExpectedResult.PASS:
-                raise Jinja2TransformUndefinedError(
-                    name=self.name, message=str_output, rtb=traceback, errors=errors
-                ) from exc
+                raise exc
             return None
 
     def get_result_differences(self, computed: Any) -> str | None:
@@ -99,8 +95,8 @@ def runtest(self) -> None:
             raise OutputMatchError(name=self.name, differences=differences)
 
     def repr_failure(self, excinfo: ExceptionInfo, style: str | None = None) -> str:
-        if isinstance(excinfo.value, (Jinja2TransformUndefinedError, Jinja2TransformError)):
-            return excinfo.value.message
+        if isinstance(excinfo.value, (JinjaTemplateError)):
+            return str(excinfo.value.message)
 
         return super().repr_failure(excinfo, style=style)
 
diff --git a/infrahub_sdk/template/__init__.py b/infrahub_sdk/template/__init__.py
new file mode 100644
index 00000000..c43f7ad9
--- /dev/null
+++ b/infrahub_sdk/template/__init__.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import linecache
+from pathlib import Path
+from typing import Any, Callable, NoReturn
+
+import jinja2
+from jinja2 import meta, nodes
+from jinja2.sandbox import SandboxedEnvironment
+from netutils.utils import jinja2_convenience_function
+from rich.syntax import Syntax
+from rich.traceback import Traceback
+
+from .exceptions import (
+    JinjaTemplateError,
+    JinjaTemplateNotFoundError,
+    JinjaTemplateOperationViolationError,
+    JinjaTemplateSyntaxError,
+    JinjaTemplateUndefinedError,
+)
+from .filters import AVAILABLE_FILTERS
+from .models import UndefinedJinja2Error
+
+netutils_filters = jinja2_convenience_function()
+
+
+class Jinja2Template:
+    def __init__(
+        self,
+        template: str | Path,
+        template_directory: Path | None = None,
+        filters: dict[str, Callable] | None = None,
+    ) -> None:
+        self.is_string_based = isinstance(template, str)
+        self.is_file_based = isinstance(template, Path)
+        self._template = str(template)
+        self._template_directory = template_directory
+        self._environment: jinja2.Environment | None = None
+
+        self._available_filters = [filter_definition.name for filter_definition in AVAILABLE_FILTERS]
+        self._trusted_filters = [
+            filter_definition.name for filter_definition in AVAILABLE_FILTERS if filter_definition.trusted
+        ]
+
+        self._filters = filters or {}
+        for user_filter in self._filters:
+            self._available_filters.append(user_filter)
+            self._trusted_filters.append(user_filter)
+
+        self._template_definition: jinja2.Template | None = None
+
+    def get_environment(self) -> jinja2.Environment:
+        if self._environment:
+            return self._environment
+
+        if self.is_string_based:
+            return self._get_string_based_environment()
+
+        return self._get_file_based_environment()
+
+    def get_template(self) -> jinja2.Template:
+        if self._template_definition:
+            return self._template_definition
+
+        try:
+            if self.is_string_based:
+                template = self._get_string_based_template()
+            else:
+                template = self._get_file_based_template()
+        except jinja2.TemplateSyntaxError as exc:
+            self._raise_template_syntax_error(error=exc)
+        except jinja2.TemplateNotFound as exc:
+            raise JinjaTemplateNotFoundError(message=exc.message, filename=str(exc.name))
+
+        return template
+
+    def get_variables(self) -> list[str]:
+        env = self.get_environment()
+
+        template_source = self._template
+        if self.is_file_based and env.loader:
+            template_source = env.loader.get_source(env, self._template)[0]
+
+        template = env.parse(template_source)
+
+        return sorted(meta.find_undeclared_variables(template))
+
+    def validate(self, restricted: bool = True) -> None:
+        allowed_list = self._available_filters
+        if restricted:
+            allowed_list = self._trusted_filters
+
+        env = self.get_environment()
+        template_source = self._template
+        if self.is_file_based and env.loader:
+            template_source = env.loader.get_source(env, self._template)[0]
+
+        template = env.parse(template_source)
+        for node in template.find_all(nodes.Filter):
+            if node.name not in allowed_list:
+                raise JinjaTemplateOperationViolationError(f"The '{node.name}' filter isn't allowed to be used")
+
+        forbidden_operations = ["Call", "Import", "Include"]
+        if self.is_string_based and any(node.__class__.__name__ in forbidden_operations for node in template.body):
+            raise JinjaTemplateOperationViolationError(
+                f"These operations are forbidden for string based templates: {forbidden_operations}"
+            )
+
+    async def render(self, variables: dict[str, Any]) -> str:
+        template = self.get_template()
+        try:
+            output = await template.render_async(variables)
+        except jinja2.exceptions.TemplateNotFound as exc:
+            raise JinjaTemplateNotFoundError(message=exc.message, filename=str(exc.name), base_template=template.name)
+        except jinja2.TemplateSyntaxError as exc:
+            self._raise_template_syntax_error(error=exc)
+        except jinja2.UndefinedError as exc:
+            traceback = Traceback(show_locals=False)
+            errors = _identify_faulty_jinja_code(traceback=traceback)
+            raise JinjaTemplateUndefinedError(message=exc.message, errors=errors)
+        except Exception as exc:
+            if error_message := getattr(exc, "message", None):
+                message = error_message
+            else:
+                message = str(exc)
+            raise JinjaTemplateError(message=message or "Unknown template error")
+
+        return output
+
+    def _get_string_based_environment(self) -> jinja2.Environment:
+        env = SandboxedEnvironment(enable_async=True, undefined=jinja2.StrictUndefined)
+        self._set_filters(env=env)
+        self._environment = env
+        return self._environment
+
+    def _get_file_based_environment(self) -> jinja2.Environment:
+        template_loader = jinja2.FileSystemLoader(searchpath=str(self._template_directory))
+        env = jinja2.Environment(
+            loader=template_loader,
+            trim_blocks=True,
+            lstrip_blocks=True,
+            enable_async=True,
+        )
+        self._set_filters(env=env)
+        self._environment = env
+        return self._environment
+
+    def _set_filters(self, env: jinja2.Environment) -> None:
+        for default_filter in list(env.filters.keys()):
+            if default_filter not in self._available_filters:
+                del env.filters[default_filter]
+
+        # Add filters from netutils
+        env.filters.update(
+            {name: jinja_filter for name, jinja_filter in netutils_filters.items() if name in self._available_filters}
+        )
+        # Add user supplied filters
+        env.filters.update(self._filters)
+
+    def _get_string_based_template(self) -> jinja2.Template:
+        env = self.get_environment()
+        self._template_definition = env.from_string(self._template)
+        return self._template_definition
+
+    def _get_file_based_template(self) -> jinja2.Template:
+        env = self.get_environment()
+        self._template_definition = env.get_template(self._template)
+        return self._template_definition
+
+    def _raise_template_syntax_error(self, error: jinja2.TemplateSyntaxError) -> NoReturn:
+        filename: str | None = None
+        if error.filename and self._template_directory:
+            filename = error.filename
+            if error.filename.startswith(str(self._template_directory)):
+                filename = error.filename[len(str(self._template_directory)) :]
+
+        raise JinjaTemplateSyntaxError(message=error.message, filename=filename, lineno=error.lineno)
+
+
+def _identify_faulty_jinja_code(traceback: Traceback, nbr_context_lines: int = 3) -> list[UndefinedJinja2Error]:
+    """This function identifies the faulty Jinja2 code and beautify it to provide meaningful information to the user.
+
+    We use the rich's Traceback to parse the complete stack trace and extract Frames for each exception found in the trace.
+    """
+    response = []
+
+    # Extract only the Jinja related exception
+    for frame in [frame for frame in traceback.trace.stacks[0].frames if not frame.filename.endswith(".py")]:
+        code = "".join(linecache.getlines(frame.filename))
+        if frame.filename == "":
+            lexer_name = "text"
+        else:
+            lexer_name = Traceback._guess_lexer(frame.filename, code)
+        syntax = Syntax(
+            code,
+            lexer_name,
+            line_numbers=True,
+            line_range=(
+                frame.lineno - nbr_context_lines,
+                frame.lineno + nbr_context_lines,
+            ),
+            highlight_lines={frame.lineno},
+            code_width=88,
+            theme=traceback.theme,
+            dedent=False,
+        )
+        response.append(UndefinedJinja2Error(frame=frame, syntax=syntax))
+
+    return response
diff --git a/infrahub_sdk/template/exceptions.py b/infrahub_sdk/template/exceptions.py
new file mode 100644
index 00000000..44fa1a1f
--- /dev/null
+++ b/infrahub_sdk/template/exceptions.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from infrahub_sdk.exceptions import Error
+
+if TYPE_CHECKING:
+    from .models import UndefinedJinja2Error
+
+
+class JinjaTemplateError(Error):
+    def __init__(self, message: str) -> None:
+        self.message = message
+
+
+class JinjaTemplateNotFoundError(JinjaTemplateError):
+    def __init__(self, message: str | None, filename: str, base_template: str | None = None) -> None:
+        self.message = message or "Template Not Found"
+        self.filename = filename
+        self.base_template = base_template
+
+
+class JinjaTemplateSyntaxError(JinjaTemplateError):
+    def __init__(self, message: str | None, lineno: int, filename: str | None = None) -> None:
+        self.message = message or "Syntax Error"
+        self.filename = filename
+        self.lineno = lineno
+
+
+class JinjaTemplateUndefinedError(JinjaTemplateError):
+    def __init__(self, message: str | None, errors: list[UndefinedJinja2Error]) -> None:
+        self.message = message or "Undefined Error"
+        self.errors = errors
+
+
+class JinjaTemplateOperationViolationError(JinjaTemplateError):
+    def __init__(self, message: str | None = None) -> None:
+        self.message = message or "Forbidden code found in the template"
diff --git a/infrahub_sdk/template/filters.py b/infrahub_sdk/template/filters.py
new file mode 100644
index 00000000..1d082b39
--- /dev/null
+++ b/infrahub_sdk/template/filters.py
@@ -0,0 +1,151 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class FilterDefinition:
+    name: str
+    trusted: bool
+    source: str
+
+
+BUILTIN_FILTERS = [
+    FilterDefinition(name="abs", trusted=True, source="jinja2"),
+    FilterDefinition(name="attr", trusted=False, source="jinja2"),
+    FilterDefinition(name="batch", trusted=False, source="jinja2"),
+    FilterDefinition(name="capitalize", trusted=True, source="jinja2"),
+    FilterDefinition(name="center", trusted=True, source="jinja2"),
+    FilterDefinition(name="count", trusted=True, source="jinja2"),
+    FilterDefinition(name="d", trusted=True, source="jinja2"),
+    FilterDefinition(name="default", trusted=True, source="jinja2"),
+    FilterDefinition(name="dictsort", trusted=False, source="jinja2"),
+    FilterDefinition(name="e", trusted=True, source="jinja2"),
+    FilterDefinition(name="escape", trusted=True, source="jinja2"),
+    FilterDefinition(name="filesizeformat", trusted=True, source="jinja2"),
+    FilterDefinition(name="first", trusted=True, source="jinja2"),
+    FilterDefinition(name="float", trusted=True, source="jinja2"),
+    FilterDefinition(name="forceescape", trusted=True, source="jinja2"),
+    FilterDefinition(name="format", trusted=True, source="jinja2"),
+    FilterDefinition(name="groupby", trusted=False, source="jinja2"),
+    FilterDefinition(name="indent", trusted=True, source="jinja2"),
+    FilterDefinition(name="int", trusted=True, source="jinja2"),
+    FilterDefinition(name="items", trusted=False, source="jinja2"),
+    FilterDefinition(name="join", trusted=True, source="jinja2"),
+    FilterDefinition(name="last", trusted=True, source="jinja2"),
+    FilterDefinition(name="length", trusted=True, source="jinja2"),
+    FilterDefinition(name="list", trusted=True, source="jinja2"),
+    FilterDefinition(name="lower", trusted=True, source="jinja2"),
+    FilterDefinition(name="map", trusted=False, source="jinja2"),
+    FilterDefinition(name="max", trusted=True, source="jinja2"),
+    FilterDefinition(name="min", trusted=True, source="jinja2"),
+    FilterDefinition(name="pprint", trusted=False, source="jinja2"),
+    FilterDefinition(name="random", trusted=False, source="jinja2"),
+    FilterDefinition(name="reject", trusted=False, source="jinja2"),
+    FilterDefinition(name="rejectattr", trusted=False, source="jinja2"),
+    FilterDefinition(name="replace", trusted=True, source="jinja2"),
+    FilterDefinition(name="reverse", trusted=True, source="jinja2"),
+    FilterDefinition(name="round", trusted=True, source="jinja2"),
+    FilterDefinition(name="safe", trusted=False, source="jinja2"),
+    FilterDefinition(name="select", trusted=False, source="jinja2"),
+    FilterDefinition(name="selectattr", trusted=False, source="jinja2"),
+    FilterDefinition(name="slice", trusted=True, source="jinja2"),
+    FilterDefinition(name="sort", trusted=False, source="jinja2"),
+    FilterDefinition(name="string", trusted=True, source="jinja2"),
+    FilterDefinition(name="striptags", trusted=True, source="jinja2"),
+    FilterDefinition(name="sum", trusted=True, source="jinja2"),
+    FilterDefinition(name="title", trusted=True, source="jinja2"),
+    FilterDefinition(name="tojson", trusted=False, source="jinja2"),
+    FilterDefinition(name="trim", trusted=True, source="jinja2"),
+    FilterDefinition(name="truncate", trusted=True, source="jinja2"),
+    FilterDefinition(name="unique", trusted=False, source="jinja2"),
+    FilterDefinition(name="upper", trusted=True, source="jinja2"),
+    FilterDefinition(name="urlencode", trusted=True, source="jinja2"),
+    FilterDefinition(name="urlize", trusted=False, source="jinja2"),
+    FilterDefinition(name="wordcount", trusted=True, source="jinja2"),
+    FilterDefinition(name="wordwrap", trusted=True, source="jinja2"),
+    FilterDefinition(name="xmlattr", trusted=False, source="jinja2"),
+]
+
+
+NETUTILS_FILTERS = [
+    FilterDefinition(name="abbreviated_interface_name", trusted=True, source="netutils"),
+    FilterDefinition(name="abbreviated_interface_name_list", trusted=True, source="netutils"),
+    FilterDefinition(name="asn_to_int", trusted=True, source="netutils"),
+    FilterDefinition(name="bits_to_name", trusted=True, source="netutils"),
+    FilterDefinition(name="bytes_to_name", trusted=True, source="netutils"),
+    FilterDefinition(name="canonical_interface_name", trusted=True, source="netutils"),
+    FilterDefinition(name="canonical_interface_name_list", trusted=True, source="netutils"),
+    FilterDefinition(name="cidr_to_netmask", trusted=True, source="netutils"),
+    FilterDefinition(name="cidr_to_netmaskv6", trusted=True, source="netutils"),
+    FilterDefinition(name="clean_config", trusted=True, source="netutils"),
+    FilterDefinition(name="compare_version_loose", trusted=True, source="netutils"),
+    FilterDefinition(name="compare_version_strict", trusted=True, source="netutils"),
+    FilterDefinition(name="config_compliance", trusted=True, source="netutils"),
+    FilterDefinition(name="config_section_not_parsed", trusted=True, source="netutils"),
+    FilterDefinition(name="delimiter_change", trusted=True, source="netutils"),
+    FilterDefinition(name="diff_network_config", trusted=True, source="netutils"),
+    FilterDefinition(name="feature_compliance", trusted=True, source="netutils"),
+    FilterDefinition(name="find_unordered_cfg_lines", trusted=True, source="netutils"),
+    FilterDefinition(name="fqdn_to_ip", trusted=False, source="netutils"),
+    FilterDefinition(name="get_all_host", trusted=False, source="netutils"),
+    FilterDefinition(name="get_broadcast_address", trusted=True, source="netutils"),
+    FilterDefinition(name="get_first_usable", trusted=True, source="netutils"),
+    FilterDefinition(name="get_ips_sorted", trusted=True, source="netutils"),
+    FilterDefinition(name="get_nist_urls", trusted=True, source="netutils"),
+    FilterDefinition(name="get_nist_vendor_platform_urls", trusted=True, source="netutils"),
+    FilterDefinition(name="get_oui", trusted=True, source="netutils"),
+    FilterDefinition(name="get_peer_ip", trusted=True, source="netutils"),
+    FilterDefinition(name="get_range_ips", trusted=True, source="netutils"),
+    FilterDefinition(name="get_upgrade_path", trusted=True, source="netutils"),
+    FilterDefinition(name="get_usable_range", trusted=True, source="netutils"),
+    FilterDefinition(name="hash_data", trusted=True, source="netutils"),
+    FilterDefinition(name="int_to_asdot", trusted=True, source="netutils"),
+    FilterDefinition(name="interface_range_compress", trusted=True, source="netutils"),
+    FilterDefinition(name="interface_range_expansion", trusted=True, source="netutils"),
+    FilterDefinition(name="ip_addition", trusted=True, source="netutils"),
+    FilterDefinition(name="ip_subtract", trusted=True, source="netutils"),
+    FilterDefinition(name="ip_to_bin", trusted=True, source="netutils"),
+    FilterDefinition(name="ip_to_hex", trusted=True, source="netutils"),
+    FilterDefinition(name="ipaddress_address", trusted=True, source="netutils"),
+    FilterDefinition(name="ipaddress_interface", trusted=True, source="netutils"),
+    FilterDefinition(name="ipaddress_network", trusted=True, source="netutils"),
+    FilterDefinition(name="is_classful", trusted=True, source="netutils"),
+    FilterDefinition(name="is_fqdn_resolvable", trusted=False, source="netutils"),
+    FilterDefinition(name="is_ip", trusted=True, source="netutils"),
+    FilterDefinition(name="is_ip_range", trusted=True, source="netutils"),
+    FilterDefinition(name="is_ip_within", trusted=True, source="netutils"),
+    FilterDefinition(name="is_netmask", trusted=True, source="netutils"),
+    FilterDefinition(name="is_network", trusted=True, source="netutils"),
+    FilterDefinition(name="is_reversible_wildcardmask", trusted=True, source="netutils"),
+    FilterDefinition(name="is_valid_mac", trusted=True, source="netutils"),
+    FilterDefinition(name="longest_prefix_match", trusted=True, source="netutils"),
+    FilterDefinition(name="mac_normalize", trusted=True, source="netutils"),
+    FilterDefinition(name="mac_to_format", trusted=True, source="netutils"),
+    FilterDefinition(name="mac_to_int", trusted=True, source="netutils"),
+    FilterDefinition(name="mac_type", trusted=True, source="netutils"),
+    FilterDefinition(name="name_to_bits", trusted=True, source="netutils"),
+    FilterDefinition(name="name_to_bytes", trusted=True, source="netutils"),
+    FilterDefinition(name="name_to_name", trusted=True, source="netutils"),
+    FilterDefinition(name="netmask_to_cidr", trusted=True, source="netutils"),
+    FilterDefinition(name="netmask_to_wildcardmask", trusted=True, source="netutils"),
+    FilterDefinition(name="normalise_delimiter_caret_c", trusted=True, source="netutils"),
+    FilterDefinition(name="paloalto_panos_brace_to_set", trusted=True, source="netutils"),
+    FilterDefinition(name="paloalto_panos_clean_newlines", trusted=True, source="netutils"),
+    FilterDefinition(name="regex_findall", trusted=False, source="netutils"),
+    FilterDefinition(name="regex_match", trusted=False, source="netutils"),
+    FilterDefinition(name="regex_search", trusted=False, source="netutils"),
+    FilterDefinition(name="regex_split", trusted=False, source="netutils"),
+    FilterDefinition(name="regex_sub", trusted=False, source="netutils"),
+    FilterDefinition(name="sanitize_config", trusted=True, source="netutils"),
+    FilterDefinition(name="section_config", trusted=True, source="netutils"),
+    FilterDefinition(name="sort_interface_list", trusted=True, source="netutils"),
+    FilterDefinition(name="split_interface", trusted=True, source="netutils"),
+    FilterDefinition(name="uptime_seconds_to_string", trusted=True, source="netutils"),
+    FilterDefinition(name="uptime_string_to_seconds", trusted=True, source="netutils"),
+    FilterDefinition(name="version_metadata", trusted=True, source="netutils"),
+    FilterDefinition(name="vlanconfig_to_list", trusted=True, source="netutils"),
+    FilterDefinition(name="vlanlist_to_config", trusted=True, source="netutils"),
+    FilterDefinition(name="wildcardmask_to_netmask", trusted=True, source="netutils"),
+]
+
+
+AVAILABLE_FILTERS = BUILTIN_FILTERS + NETUTILS_FILTERS
diff --git a/infrahub_sdk/template/models.py b/infrahub_sdk/template/models.py
new file mode 100644
index 00000000..e40393ab
--- /dev/null
+++ b/infrahub_sdk/template/models.py
@@ -0,0 +1,10 @@
+from dataclasses import dataclass
+
+from rich.syntax import Syntax
+from rich.traceback import Frame
+
+
+@dataclass
+class UndefinedJinja2Error:
+    frame: Frame
+    syntax: Syntax
diff --git a/poetry.lock b/poetry.lock
index 39b0b6de..9938fd39 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -977,6 +977,20 @@ files = [
     {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
 ]
 
+[[package]]
+name = "netutils"
+version = "1.12.0"
+description = "Common helper functions useful in network automation."
+optional = false
+python-versions = "<4.0,>=3.8"
+files = [
+    {file = "netutils-1.12.0-py3-none-any.whl", hash = "sha256:7cb37796ce86637814f8c899f64db2b054986b0eda719d3fcadc293d451a4db1"},
+    {file = "netutils-1.12.0.tar.gz", hash = "sha256:96a790d11921063a6a64ee79c6e8c5a5ffcd05cbee07dd2b614d98c4416cffdd"},
+]
+
+[package.extras]
+optionals = ["jsonschema (>=4.17.3,<5.0.0)", "legacycrypt (==0.3)", "napalm (>=4.0.0,<5.0.0)"]
+
 [[package]]
 name = "nodeenv"
 version = "1.9.1"
@@ -2325,4 +2339,4 @@ tests = ["Jinja2", "pytest", "pyyaml", "rich"]
 [metadata]
 lock-version = "2.1"
 python-versions = "^3.9, <3.14"
-content-hash = "b3e5f33a5e7089dfb49e9d4fd41b71feba6a5f2ec50c67f18202caa973baf1b3"
+content-hash = "b2747ad942541d2b546562e33cc9cb6d84b26f3d5ca10d72e8f24f55e2a9492e"
diff --git a/pyproject.toml b/pyproject.toml
index 704d6bec..d28289cd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ pyyaml = { version = "^6", optional = true }
 eval-type-backport = { version = "^0.2.2", python = "~3.9" }
 dulwich = "^0.21.4"
 whenever = "0.7.2"
+netutils = "^1.0.0"
 
 [tool.poetry.group.dev.dependencies]
 pytest = "*"
diff --git a/tasks.py b/tasks.py
index cea9a59a..b5e00b17 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,3 +1,4 @@
+import asyncio
 import sys
 from pathlib import Path
 from typing import Any
@@ -14,6 +15,7 @@ def _generate(context: Context) -> None:
     """Generate documentation output from code."""
     _generate_infrahubctl_documentation(context=context)
     _generate_infrahub_sdk_configuration_documentation()
+    _generate_infrahub_sdk_template_documentation()
 
 
 def _generate_infrahubctl_documentation(context: Context) -> None:
@@ -89,6 +91,24 @@ def _generate_infrahub_sdk_configuration_documentation() -> None:
     print(f"Docs saved to: {output_file}")
 
 
+def _generate_infrahub_sdk_template_documentation() -> None:
+    """Generate documentation for the Infrahub SDK template reference."""
+    from infrahub_sdk.template import Jinja2Template
+    from infrahub_sdk.template.filters import BUILTIN_FILTERS, NETUTILS_FILTERS
+
+    output_file = DOCUMENTATION_DIRECTORY / "docs" / "python-sdk" / "reference" / "templating.mdx"
+    jinja2_template = Jinja2Template(
+        template=Path("sdk_template_reference.j2"),
+        template_directory=DOCUMENTATION_DIRECTORY / "_templates",
+    )
+
+    rendered_file = asyncio.run(
+        jinja2_template.render(variables={"builtin": BUILTIN_FILTERS, "netutils": NETUTILS_FILTERS})
+    )
+    output_file.write_text(rendered_file, encoding="utf-8")
+    print(f"Docs saved to: {output_file}")
+
+
 def _get_env_vars() -> dict[str, list[str]]:
     """Retrieve environment variables for Infrahub SDK configuration."""
     from collections import defaultdict
@@ -170,7 +190,7 @@ def docs_build(context: Context) -> None:
     with context.cd(DOCUMENTATION_DIRECTORY):
         output = context.run(exec_cmd)
 
-    if output.exited != 0:
+    if output and output.exited != 0:
         sys.exit(-1)
 
 
@@ -184,3 +204,4 @@ def generate_infrahubctl(context: Context) -> None:
 def generate_python_sdk(context: Context) -> None:  # noqa: ARG001
     """Generate documentation for the Python SDK."""
     _generate_infrahub_sdk_configuration_documentation()
+    _generate_infrahub_sdk_template_documentation()
diff --git a/tests/fixtures/repos/missing_template_file/.infrahub.yml b/tests/fixtures/repos/missing_template_file/.infrahub.yml
new file mode 100644
index 00000000..207f5f2e
--- /dev/null
+++ b/tests/fixtures/repos/missing_template_file/.infrahub.yml
@@ -0,0 +1,18 @@
+---
+jinja2_transforms:
+  - name: tag_format_missing
+    query: "tags_query"
+    template_path: "tag_format.file-is-missing"
+  - name: undefined_variables
+    query: "tags_query"
+    template_path: "templates/undefined.j2"
+  - name: syntax_error
+    query: "tags_query"
+    template_path: "templates/syntax-error.html"
+  - name: missing_filter
+    query: "tags_query"
+    template_path: "templates/wrong-filter.j2"
+
+queries:
+  - name: tags_query
+    file_path: tags_query.gql
diff --git a/tests/fixtures/repos/missing_template_file/tags_query.gql b/tests/fixtures/repos/missing_template_file/tags_query.gql
new file mode 100644
index 00000000..6d2ea6ab
--- /dev/null
+++ b/tests/fixtures/repos/missing_template_file/tags_query.gql
@@ -0,0 +1,11 @@
+query TagsQuery($name: String!) {
+  BuiltinTag(name__value: $name) {
+    edges {
+      node {
+        name {
+          value
+        }
+      }
+    }
+  }
+}
diff --git a/tests/fixtures/repos/missing_template_file/templates/syntax-error.html b/tests/fixtures/repos/missing_template_file/templates/syntax-error.html
new file mode 100644
index 00000000..31730cc6
--- /dev/null
+++ b/tests/fixtures/repos/missing_template_file/templates/syntax-error.html
@@ -0,0 +1,5 @@
+
+
+{{ title }
+
+
\ No newline at end of file
diff --git a/tests/fixtures/repos/missing_template_file/templates/undefined.j2 b/tests/fixtures/repos/missing_template_file/templates/undefined.j2
new file mode 100644
index 00000000..fb8e02e8
--- /dev/null
+++ b/tests/fixtures/repos/missing_template_file/templates/undefined.j2
@@ -0,0 +1 @@
+hostname {{ host.name }}
\ No newline at end of file
diff --git a/tests/fixtures/repos/missing_template_file/templates/wrong-filter.j2 b/tests/fixtures/repos/missing_template_file/templates/wrong-filter.j2
new file mode 100644
index 00000000..d2a8f283
--- /dev/null
+++ b/tests/fixtures/repos/missing_template_file/templates/wrong-filter.j2
@@ -0,0 +1 @@
+{{ data|my_filter_is_missing }} 
\ No newline at end of file
diff --git a/tests/fixtures/unit/test_infrahubctl/red_tags_query/red_tag.json b/tests/fixtures/unit/test_infrahubctl/red_tags_query/red_tag.json
new file mode 100644
index 00000000..51438236
--- /dev/null
+++ b/tests/fixtures/unit/test_infrahubctl/red_tags_query/red_tag.json
@@ -0,0 +1,15 @@
+{
+    "data": {
+      "BuiltinTag": {
+        "edges": [
+          {
+            "node": {
+              "name": {
+                "value": "red"
+              }
+            }
+          }
+        ]
+      }
+    }
+  }
\ No newline at end of file
diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py
index 1cfd6a5c..9de4ee71 100644
--- a/tests/helpers/utils.py
+++ b/tests/helpers/utils.py
@@ -2,8 +2,13 @@
 
 import os
 import re
+import shutil
+import tempfile
 from collections.abc import Generator
 from contextlib import contextmanager
+from pathlib import Path
+
+from infrahub_sdk.repository import GitRepoManager
 
 
 @contextmanager
@@ -22,6 +27,21 @@ def change_directory(new_directory: str) -> Generator[None, None, None]:
         os.chdir(original_directory)
 
 
+@contextmanager
+def temp_repo_and_cd(source_dir: Path) -> Generator[Path, None, None]:
+    temp_dir = tempfile.mkdtemp()
+    original_directory = os.getcwd()
+
+    try:
+        shutil.copytree(source_dir, temp_dir, dirs_exist_ok=True)
+        GitRepoManager(temp_dir)  # assuming this is defined elsewhere
+        os.chdir(temp_dir)
+        yield Path(temp_dir)
+    finally:
+        os.chdir(original_directory)
+        shutil.rmtree(temp_dir)
+
+
 def strip_color(text: str) -> str:
     ansi_escape = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]")
     return ansi_escape.sub("", text)
diff --git a/tests/unit/ctl/test_render_app.py b/tests/unit/ctl/test_render_app.py
new file mode 100644
index 00000000..dceba985
--- /dev/null
+++ b/tests/unit/ctl/test_render_app.py
@@ -0,0 +1,75 @@
+import json
+import os
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+from pytest_httpx._httpx_mock import HTTPXMock
+from typer.testing import CliRunner
+
+from infrahub_sdk.ctl.cli_commands import app
+from tests.helpers.fixtures import read_fixture
+from tests.helpers.utils import strip_color, temp_repo_and_cd
+
+runner = CliRunner()
+
+
+FIXTURE_BASE_DIR = Path(Path(os.path.abspath(__file__)).parent / ".." / ".." / "fixtures" / "repos")
+
+requires_python_310 = pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10 or higher")
+
+
+@dataclass
+class RenderAppFailure:
+    name: str
+    template: str
+    error: str
+
+
+RENDER_APP_FAIL_TEST_CASES = [
+    RenderAppFailure(
+        name="main-template-not-found",
+        template="tag_format_missing",
+        error="Missing template: tag_format.file-is-missing",
+    ),
+    RenderAppFailure(
+        name="has-undefined-variables",
+        template="undefined_variables",
+        error="'host' is undefined",
+    ),
+    RenderAppFailure(
+        name="has-syntax-error",
+        template="syntax_error",
+        error="unexpected '}'",
+    ),
+    RenderAppFailure(
+        name="invalid-filter",
+        template="missing_filter",
+        error="No filter named 'my_filter_is_missing'.",
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [pytest.param(tc, id=tc.name) for tc in RENDER_APP_FAIL_TEST_CASES],
+)
+@requires_python_310
+def test_validate_template_not_found(test_case: RenderAppFailure, httpx_mock: HTTPXMock) -> None:
+    """Ensure that the correct errors are caught"""
+    httpx_mock.add_response(
+        method="POST",
+        url="http://mock/graphql/main",
+        json=json.loads(
+            read_fixture(
+                "red_tag.json",
+                "unit/test_infrahubctl/red_tags_query",
+            )
+        ),
+    )
+
+    with temp_repo_and_cd(source_dir=FIXTURE_BASE_DIR / "missing_template_file"):
+        output = runner.invoke(app, ["render", test_case.template, "name=red"])
+        assert test_case.error in strip_color(output.stdout)
+        assert output.exit_code == 1
diff --git a/tests/unit/sdk/test_data/templates/broken_on_line6.j2 b/tests/unit/sdk/test_data/templates/broken_on_line6.j2
new file mode 100644
index 00000000..f7ce8b24
--- /dev/null
+++ b/tests/unit/sdk/test_data/templates/broken_on_line6.j2
@@ -0,0 +1,8 @@
+# Included file
+
+## Subsection
+
+* {{ name }}
+* {{ name }
+
+# The end
diff --git a/tests/unit/sdk/test_data/templates/hello-world.j2 b/tests/unit/sdk/test_data/templates/hello-world.j2
new file mode 100644
index 00000000..d7732488
--- /dev/null
+++ b/tests/unit/sdk/test_data/templates/hello-world.j2
@@ -0,0 +1 @@
+Hello {{ name }}
\ No newline at end of file
diff --git a/tests/unit/sdk/test_data/templates/imports-missing-file.html b/tests/unit/sdk/test_data/templates/imports-missing-file.html
new file mode 100644
index 00000000..b7006ae1
--- /dev/null
+++ b/tests/unit/sdk/test_data/templates/imports-missing-file.html
@@ -0,0 +1,8 @@
+
+
+    Some Title
+
+
+{% include 'i-do-not-exist.html' %}
+
+
\ No newline at end of file
diff --git a/tests/unit/sdk/test_data/templates/index.html b/tests/unit/sdk/test_data/templates/index.html
new file mode 100644
index 00000000..46bf6501
--- /dev/null
+++ b/tests/unit/sdk/test_data/templates/index.html
@@ -0,0 +1,8 @@
+
+
+    Some Title
+
+
+{{ highlight }
+
+
\ No newline at end of file
diff --git a/tests/unit/sdk/test_data/templates/ip_report.j2 b/tests/unit/sdk/test_data/templates/ip_report.j2
new file mode 100644
index 00000000..98f26ef3
--- /dev/null
+++ b/tests/unit/sdk/test_data/templates/ip_report.j2
@@ -0,0 +1 @@
+IP Address: {{ address | ipaddress_interface("ip") }}/{{ address | ipaddress_interface("network") | ipaddress_network("prefixlen") }}
\ No newline at end of file
diff --git a/tests/unit/sdk/test_data/templates/main.j2 b/tests/unit/sdk/test_data/templates/main.j2
new file mode 100644
index 00000000..7268ba49
--- /dev/null
+++ b/tests/unit/sdk/test_data/templates/main.j2
@@ -0,0 +1,6 @@
+Some text that works just fine
+
+{% include 'broken_on_line6.j2' %}
+
+Hello {{ name }}
+
diff --git a/tests/unit/sdk/test_data/templates/report.html b/tests/unit/sdk/test_data/templates/report.html
new file mode 100644
index 00000000..c02ee5fd
--- /dev/null
+++ b/tests/unit/sdk/test_data/templates/report.html
@@ -0,0 +1,11 @@
+
+
+
+{% for server in servers %}
+    - {{server.name}}: {{ server.ip.primary }}+{% endfor %}
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/unit/sdk/test_template.py b/tests/unit/sdk/test_template.py
new file mode 100644
index 00000000..b8854e54
--- /dev/null
+++ b/tests/unit/sdk/test_template.py
@@ -0,0 +1,312 @@
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import pytest
+from rich.syntax import Syntax
+from rich.traceback import Frame
+
+from infrahub_sdk.template import Jinja2Template
+from infrahub_sdk.template.exceptions import (
+    JinjaTemplateError,
+    JinjaTemplateNotFoundError,
+    JinjaTemplateOperationViolationError,
+    JinjaTemplateSyntaxError,
+    JinjaTemplateUndefinedError,
+)
+from infrahub_sdk.template.filters import (
+    BUILTIN_FILTERS,
+    NETUTILS_FILTERS,
+    FilterDefinition,
+)
+from infrahub_sdk.template.models import UndefinedJinja2Error
+
+CURRENT_DIRECTORY = Path(__file__).parent
+TEMPLATE_DIRECTORY = CURRENT_DIRECTORY / "test_data/templates"
+
+
+@dataclass
+class JinjaTestCase:
+    name: str
+    template: str
+    variables: dict[str, Any]
+    expected: str
+    expected_variables: list[str] = field(default_factory=list)
+
+
+@dataclass
+class JinjaTestCaseFailing:
+    name: str
+    template: str
+    variables: dict[str, Any]
+    error: JinjaTemplateError
+
+
+SUCCESSFUL_STRING_TEST_CASES = [
+    JinjaTestCase(
+        name="hello-world",
+        template="Hello {{ name }}",
+        variables={"name": "Infrahub"},
+        expected="Hello Infrahub",
+        expected_variables=["name"],
+    ),
+    JinjaTestCase(
+        name="hello-if-defined",
+        template="Hello {% if name is undefined %}stranger{% else %}{{name}}{% endif %}",
+        variables={"name": "OpsMill"},
+        expected="Hello OpsMill",
+        expected_variables=["name"],
+    ),
+    JinjaTestCase(
+        name="hello-if-undefined",
+        template="Hello {% if name is undefined %}stranger{% else %}{{name}}{% endif %}",
+        variables={},
+        expected="Hello stranger",
+        expected_variables=["name"],
+    ),
+    JinjaTestCase(
+        name="netutils-ip-addition",
+        template="IP={{ ip_address|ip_addition(200) }}",
+        variables={"ip_address": "192.168.12.15"},
+        expected="IP=192.168.12.215",
+        expected_variables=["ip_address"],
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [pytest.param(tc, id=tc.name) for tc in SUCCESSFUL_STRING_TEST_CASES],
+)
+async def test_render_string(test_case: JinjaTestCase) -> None:
+    jinja = Jinja2Template(template=test_case.template)
+    assert test_case.expected == await jinja.render(variables=test_case.variables)
+    assert test_case.expected_variables == jinja.get_variables()
+
+
+SUCCESSFUL_FILE_TEST_CASES = [
+    JinjaTestCase(
+        name="hello-world",
+        template="hello-world.j2",
+        variables={"name": "Infrahub"},
+        expected="Hello Infrahub",
+        expected_variables=["name"],
+    ),
+    JinjaTestCase(
+        name="netutils-convert-address",
+        template="ip_report.j2",
+        variables={"address": "192.168.18.40/255.255.255.0"},
+        expected="IP Address: 192.168.18.40/24",
+        expected_variables=["address"],
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [pytest.param(tc, id=tc.name) for tc in SUCCESSFUL_FILE_TEST_CASES],
+)
+async def test_render_template_from_file(test_case: JinjaTestCase) -> None:
+    jinja = Jinja2Template(template=Path(test_case.template), template_directory=TEMPLATE_DIRECTORY)
+    assert test_case.expected == await jinja.render(variables=test_case.variables)
+    assert test_case.expected_variables == jinja.get_variables()
+    assert jinja.get_template()
+
+
+FAILING_STRING_TEST_CASES = [
+    JinjaTestCaseFailing(
+        name="missing-closing-end-if",
+        template="Hello {% if name is undefined %}stranger{% else %}{{name}}{% endif",
+        variables={},
+        error=JinjaTemplateSyntaxError(
+            message="unexpected end of template, expected 'end of statement block'.",
+            lineno=1,
+        ),
+    ),
+    JinjaTestCaseFailing(
+        name="fail-on-line-2",
+        template="Hello \n{{ name }",
+        variables={},
+        error=JinjaTemplateSyntaxError(
+            message="unexpected '}'",
+            lineno=2,
+        ),
+    ),
+    JinjaTestCaseFailing(
+        name="nested-undefined",
+        template="Hello {{ person.firstname }}",
+        variables={"person": {"lastname": "Rogers"}},
+        error=JinjaTemplateUndefinedError(
+            message="'dict object' has no attribute 'firstname'",
+            errors=[
+                UndefinedJinja2Error(
+                    frame=Frame(filename="", lineno=1, name="top-level template code"),
+                    syntax=Syntax(code="", lexer="text"),
+                )
+            ],
+        ),
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [pytest.param(tc, id=tc.name) for tc in FAILING_STRING_TEST_CASES],
+)
+async def test_render_string_errors(test_case: JinjaTestCaseFailing) -> None:
+    jinja = Jinja2Template(template=test_case.template, template_directory=TEMPLATE_DIRECTORY)
+    with pytest.raises(test_case.error.__class__) as exc:
+        await jinja.render(variables=test_case.variables)
+
+    _compare_errors(expected=test_case.error, received=exc.value)
+
+
+FAILING_FILE_TEST_CASES = [
+    JinjaTestCaseFailing(
+        name="missing-initial-file",
+        template="missing.html",
+        variables={},
+        error=JinjaTemplateNotFoundError(
+            message=f"'missing.html' not found in search path: '{TEMPLATE_DIRECTORY}'",
+            filename="missing.html",
+        ),
+    ),
+    JinjaTestCaseFailing(
+        name="broken-template",
+        template="index.html",
+        variables={},
+        error=JinjaTemplateSyntaxError(
+            message="unexpected '}'",
+            filename="/index.html",
+            lineno=6,
+        ),
+    ),
+    JinjaTestCaseFailing(
+        name="secondary-import-broken",
+        template="main.j2",
+        variables={},
+        error=JinjaTemplateSyntaxError(
+            message="unexpected '}'",
+            filename="/broken_on_line6.j2",
+            lineno=6,
+        ),
+    ),
+    JinjaTestCaseFailing(
+        name="secondary-import-missing",
+        template="imports-missing-file.html",
+        variables={},
+        error=JinjaTemplateNotFoundError(
+            message=f"'i-do-not-exist.html' not found in search path: '{TEMPLATE_DIRECTORY}'",
+            filename="i-do-not-exist.html",
+            base_template="imports-missing-file.html",
+        ),
+    ),
+    JinjaTestCaseFailing(
+        name="invalid-variable-input",
+        template="report.html",
+        variables={
+            "servers": [
+                {"name": "server1", "ip": {"primary": "172.18.12.1"}},
+                {"name": "server1"},
+            ]
+        },
+        error=JinjaTemplateUndefinedError(
+            message="'dict object' has no attribute 'ip'",
+            errors=[
+                UndefinedJinja2Error(
+                    frame=Frame(
+                        filename=f"{TEMPLATE_DIRECTORY}/report.html",
+                        lineno=5,
+                        name="top-level template code",
+                    ),
+                    syntax=Syntax(
+                        code="\n\n\n{% for server in servers %}\n    - {{server.name}}: {{ server.ip.primary }}\n{% endfor %}\n
\n\n\n\n\n",  # noqa E501
+                        lexer="",
+                    ),
+                )
+            ],
+        ),
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "test_case",
+    [pytest.param(tc, id=tc.name) for tc in FAILING_FILE_TEST_CASES],
+)
+async def test_manage_file_based_errors(test_case: JinjaTestCaseFailing) -> None:
+    jinja = Jinja2Template(template=Path(test_case.template), template_directory=TEMPLATE_DIRECTORY)
+    with pytest.raises(test_case.error.__class__) as exc:
+        await jinja.render(variables=test_case.variables)
+
+    _compare_errors(expected=test_case.error, received=exc.value)
+
+
+async def test_manage_unhandled_error() -> None:
+    jinja = Jinja2Template(
+        template="Hello {{ number | divide_by_zero }}",
+        filters={"divide_by_zero": _divide_by_zero},
+    )
+    with pytest.raises(JinjaTemplateError) as exc:
+        await jinja.render(variables={"number": 1})
+
+    assert exc.value.message == "division by zero"
+
+
+async def test_validate_filter() -> None:
+    jinja = Jinja2Template(template="{{ network | get_all_host }}")
+    jinja.validate(restricted=False)
+    with pytest.raises(JinjaTemplateOperationViolationError) as exc:
+        jinja.validate(restricted=True)
+
+    assert exc.value.message == "The 'get_all_host' filter isn't allowed to be used"
+
+
+async def test_validate_operation() -> None:
+    jinja = Jinja2Template(template="Hello {% include 'very-forbidden.j2' %}")
+    with pytest.raises(JinjaTemplateOperationViolationError) as exc:
+        jinja.validate(restricted=True)
+
+    assert (
+        exc.value.message == "These operations are forbidden for string based templates: ['Call', 'Import', 'Include']"
+    )
+
+
+@pytest.mark.parametrize(
+    "filter_collection",
+    [
+        pytest.param(BUILTIN_FILTERS, id="builtin-filters"),
+        pytest.param(NETUTILS_FILTERS, id="netutils-filters"),
+    ],
+)
+def test_validate_filter_sorting(filter_collection: list[FilterDefinition]) -> None:
+    """Test to validate that the filter names are in alphabetical order, for the docs and general sanity."""
+    names = [filter_definition.name for filter_definition in filter_collection]
+    assert names == sorted(names)
+
+
+def _divide_by_zero(number: int) -> float:
+    return number / 0
+
+
+def _compare_errors(expected: JinjaTemplateError, received: JinjaTemplateError) -> None:
+    if isinstance(expected, JinjaTemplateNotFoundError) and isinstance(received, JinjaTemplateNotFoundError):
+        assert expected.message == received.message
+        assert expected.filename == received.filename
+        assert expected.base_template == received.base_template
+    elif isinstance(expected, JinjaTemplateSyntaxError) and isinstance(received, JinjaTemplateSyntaxError):
+        assert expected.message == received.message
+        assert expected.filename == received.filename
+        assert expected.lineno == received.lineno
+    elif isinstance(expected, JinjaTemplateUndefinedError) and isinstance(received, JinjaTemplateUndefinedError):
+        assert expected.message == received.message
+        assert len(expected.errors) == len(received.errors)
+        for i in range(len(expected.errors)):
+            assert expected.errors[i].frame.name == received.errors[i].frame.name
+            assert expected.errors[i].frame.filename == received.errors[i].frame.filename
+            assert expected.errors[i].frame.lineno == received.errors[i].frame.lineno
+            assert expected.errors[i].syntax.code == received.errors[i].syntax.code
+            assert expected.errors[i].syntax.lexer.__class__ == received.errors[i].syntax.lexer.__class__
+
+    else:
+        raise Exception("This should never happen")