AWS AppSync’s Velocity Templating Language (VTL) can transform data between your client’s request and your data sources, but it’s not just for simple renames; it’s a full-fledged transformation engine that can reshape complex nested structures and even execute logic.

Let’s see VTL in action with a common scenario: a client sends a flat createUser mutation, but our DynamoDB table expects a nested structure with a details object.

Client Request (GraphQL):

mutation CreateUser($userId: ID!, $firstName: String!, $lastName: String!) {
  createUser(userId: $userId, firstName: $firstName, lastName: $lastName) {
    id
    name
    createdAt
  }
}

Client Variables:

{
  "userId": "user-123",
  "firstName": "Alice",
  "lastName": "Smith"
}

AppSync Resolver (Request Mapping Template for createUser):

{
  "version": "2018-05-29",
  "operation": "PutItem",
  "key": {
    "userId": $util.dynamodb.toDynamoDBJson($ctx.args.userId)
  },
  "attributeValues": $util.dynamodb.toMapValues({
    "name": "${ctx.args.firstName} ${ctx.args.lastName}",
    "details": {
      "firstName": $ctx.args.firstName,
      "lastName": $ctx.args.lastName
    },
    "createdAt": $util.time.nowISO8601()
  })
}

AppSync Resolver (Response Mapping Template for createUser):

#if($ctx.error)
  $util.error($ctx.error.message, $ctx.error.type)
#end
{
  "id": "$ctx.result.userId",
  "name": "$ctx.result.name",
  "createdAt": "$ctx.result.createdAt"
}

Notice how the attributeValues in the request template takes the flat firstName and lastName from $ctx.args and constructs a new nested details object. It also creates a combined name field and adds a createdAt timestamp. The response template then reshapes the DynamoDB result back into the shape expected by the GraphQL query.

This is the core problem VTL solves: bridging the schema gap between your API and your data sources. AppSync is a GraphQL service, but your backend might be anything – DynamoDB, Lambda, Elasticsearch, or even other HTTP endpoints. VTL is the translation layer that ensures data flows correctly between them.

How it Works Internally:

When a GraphQL request hits AppSync, it’s first parsed. For fields with resolvers attached, AppSync executes the request mapping template. This template is written in VTL and has access to the $ctx object, which contains context about the request, including arguments ($ctx.args), source object ($ctx.source for fields on existing objects), identity information ($ctx.identity), and more. The VTL code evaluates to a JSON payload that AppSync sends to your data source.

After the data source returns a result, AppSync executes the response mapping template. This template also uses VTL and has access to the $ctx object, but now $ctx.result contains the data returned by the data source. The VTL code evaluates to the final JSON response that AppSync sends back to the client, matching the GraphQL schema.

The Levers You Control:

  • $ctx.args: The arguments passed to the GraphQL field. This is your primary input.
  • $ctx.source: For fields on a parent object, this is the result of the parent resolver. Crucial for nested queries.
  • $ctx.identity: Information about the authenticated user (e.g., $ctx.identity.sub for Cognito User Pools).
  • $util: A utility object providing functions for common tasks:
    • $util.dynamodb: For interacting with DynamoDB (converting types, building expressions).
    • $util.toJson(): To serialize arbitrary data structures into JSON.
    • $util.parseJson(): To parse JSON strings.
    • $util.time: For timestamp manipulation.
    • $util.urlEncode() and $util.urlDecode(): For URL manipulation.
    • $util.appendError(): To add errors to the response.
  • Conditional Logic (#if, #elseif, #else, #end): You can conditionally transform data or even short-circuit the request.
  • Loops (#foreach): Iterate over lists to transform multiple items.

A powerful, often overlooked aspect of VTL is its ability to perform complex data validation and error generation directly within the template, before even hitting your backend. For example, you can check if a required argument is missing and return a specific GraphQL error.

#if(!$ctx.args.input.email || !$ctx.args.input.password)
  $util.error("Email and password are required for login.", "LoginError")
#end
{
  "version": "2018-05-29",
  "operation": "Invoke",
  "payload": $util.toJson({
    "username": $ctx.args.input.email,
    "password": $ctx.args.input.password
  })
}

This allows you to enforce API-level constraints and provide immediate feedback to the client, reducing the burden on your data sources.

The next step is understanding how to integrate multiple data sources within a single AppSync operation using operations in the request mapping template.

Want structured learning?

Take the full Apigateway course →