✨ Sponsored Content ✨

Post Terraform v1.0 features I love

There are a few features that have been introduced into Terraform since the release of v1 which I think are extremely exciting. Those features are:

Today's post is going to go through these three features and show you how you can use them in your Terraform code!

Pre & Post Conditions#

Pre/Post Conditions are part of a resources (or data sources) lifecycle block, where you can control the resources lifecycle conditions more deeply, as such these conditions are evaluated as early as possible. An output can also make use of the precondition.

Why would you use these conditions?

precondition/postcondition's are extremely useful as they allow us to check properties or other resources before and after our resources are created. For instance, we might create a Kubernetes cluster and want to ensure that there are no public endpoints created, this would be done with a postcondition. Another example would be checking that a particular resource has a particular tag is available in your tags variable, this would be achieved using the precondition.

I see these conditions as the most useful inside of a module, especially when the interface is a little more relaxed. This means we can allow our engineers to be creative with their implementations but still enforce guardrails.

Below is an example of the format that is required for these conditions wherever they are used:

postcondition {
  condition     = self.encrypted
  error_message = "Err: the pre/post condition failed."

Similar to Input Variable Validation the precondition/postcondition have two fields:

We will dive into a simple code example of this now. Please note that there will be references to resources and variables that I won't show in the example but I will put the full script at the end of this post.


resource "azurerm_virtual_network" "this" {
  name                = format("vn-%s", local.suffix)
  location            = var.region
  resource_group_name = azurerm_resource_group.this.name

  address_space = var.network.address_space
  dns_servers   = var.network.dns_servers != null ? var.network.dns_servers : []

  tags = local.tags

  lifecycle {
precondition {
condition = azurerm_resource_group.this.location == var.region
error_message = "Err: resource group in incorrect region."
precondition {
condition = contains(keys(local.tags), "Environment")
error_message = "Err: no environment tag present."
} ...

In the above example, I have highlighted the two precondition's we have. The first one is ensuring that the location property of our resource group matches var.region, this is useful in validating that resources are created in the correct place. The second is validating that local.tags has a key named Environment.

Now let's have a look at a postcondition:


resource "azurerm_subnet" "this" {
  for_each = {
    for v in var.network.subnets :
    v.name => v

  name                 = format("sn-%s-%s", local.suffix, each.value.name)
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name

  address_prefixes = each.value.address_space

  lifecycle {
postcondition {
condition = length(self.delegation) == 0
error_message = "Err: subnet delegation in on the subnet."
} } ...

Our example above shows a subnet that we are trying to create, once that resource is created we want to ensure that there are no delegations. We are doing that check via a postcondition. The self object is a special object that refers to the resource that has been created, similar to an each within a for_each loop. Using postconditions allows us to validate the state of a created resource immediately after its creation.

Whilst these examples are somewhat contrived hopefully they help to articulate why precondition/postcondition's are useful.

Nullable Input Variables#

Over the years there have been many times where I have an input variable defined but I don't want to pass it in every time I call my code, I would normally resolve this by creating a default with "sensible" values in there. Whilst this might work it is not always what you want, on occasion you might want to define a variable and only pass it in when it's really required one option is the nullable property which means our default value can be null and we can check and deal with that in the code!

To me this is extremely exciting, lets's have a look at an example.


variable "tags" {
  type = map(string)
  default = null

nullable = true
} locals { tags = merge(var.tags, { Environment = var.environment }) } resource "azurerm_resource_group" "this" { name = format("rg-%s", local.suffix) location = var.region tags = local.tags } ...

In the above example, we have a tags input variable, which we might not always want to provide a real value for. Traditionally we would set the default to be {} given that this variable is of type map(string) and to be honest even now I would still do this! However, for the sake of this example, we can set the default to be null as we have the property nullable defined as true. By doing this we are not forced into passing a value for this input variable into our code and can validate at the point of use if it is null or not. In some cases, it is easier to do a null check over checking the contents.

One way this might be incredibly useful is if you have a module defined that is consumed by numerous engineers and you want to add a new feature but ensure backwards compatibility utilising nullable makes this a breeze!

Optional Input Variable Attributes#

The final, and in my opinion the most exciting feature is optional!! This is coming out shortly with the v1.3 release of Terraform.

If like me you tend to opt for complex input variables using object({ ... }) then one thing that has always been a struggle is the fact that all properties of that object were required, this usually meant there were a fair few null/{}/[]/"" in my code which, to be honest is pretty unpleasant. With optional we have the ability to mark a property on an object as optional, so we don't have to pass in all the properties if we don't need to! The optional attribute also allows us to set a default value for a given property similar to a default value for an entire input variable.

Let's have a look at an example.


variable "network" {
  type = object({
    address_space           = list(string)
dns_servers = optional(list(string))
flow_timeout_in_minutes = optional(number, 15)
subnets = optional(list( object({ name = string address_space = list(string) }) )) }) default = { address_space = [""] subnets = [ { name = "0" address_space = [""] } ] } } ...

As you can see on the highlighted lines we are wrapping the type of our dns_servers and flow_timeout_in_minutes properties in the optional() attribute, this is letting Terraform know that this property does not need to be set when we pass in our object. It is important to note however, if we do not pass that object in that the property will be set to null if no default value is set on the optional attribute so any code that consumes this will need to do a null check.

By requiring our code to do null checks it is possible that we might introduce more complexity, as such I would advise caution and proper thought before using this feature.

Final Thoughts#

So today we have looked at my three favourite new features from v1.0 to v1.3 Terraform, precondition, postcondition, nullable input variables and optional. All three of these are absolutely fantastic features in my opinion and I am extremely excited that we have them!

With precondition and postcondition, we can validate/ensure the state of our resources, data sources, and outputs. Having a nullable input variable means we can keep our consumption interface to a bare minimum, as well as an easier way to implement backwards compatibility. The optional attribute allows us to negate the requirement to pass in a particular property on an object, it also allows us to set a default value for a given property!

Brendan Thompson

Principal Cloud Engineer

Discuss on Twitter