Post

jq and yq: transforming embedded key-value strings in YAML

jq and yq combine to turn a YAML file's embedded key=value strings into properly structured, typed fields, using a single scan and from_entries pipeline.

A YAML file with key-value pairs jammed into a single string field, info: height=170 age=25 gender="female" school="MIT University", needs a proper structure before anything downstream can use it. yq moves between YAML and JSON; jq does the actual reshaping. Together they turn that flat string into typed fields.

The input and the target

1
2
3
# input.yml
name: Kate
info: height=170 age=25 gender="female" school="MIT University"
1
2
3
4
5
6
# desired output
name: Kate
height: "170"
age: "25"
gender: female
school: MIT University

Building the pipeline

Convert to JSON first, since jq doesn’t speak YAML:

1
yq -o json input.yml > input.json

The transformation itself is one scan over the info string, pulling out key=value and key="quoted value" pairs, followed by from_entries to turn the array of pairs into an object:

.info |
[scan("(?<key>[^ =]+)=(?<value>\"[^\"]+\"|[^\" ]+)") |
{
  key: .[0],
  value: .[1]|(if startswith("\"") then .[1:-1] else . end)
}] |
from_entries
  • (?<key>[^ =]+)= captures everything before = as the key
  • (?<value>\"[^\"]+\"|[^\" ]+) captures either a quoted value or a bare token
  • the if startswith strips the surrounding quotes when present
  • from_entries converts [{key:, value:}, ...] into a plain object

Merged into a single pipeline, replacing info with its parsed fields:

1
2
3
4
yq -ojson input.yml | \
  jq '.+(.info|[scan("(?<key>[^ =]+)=(?<value>\"[^\"]+\"|[^\" ]+)") | \
  {key: .[0], value: .[1]|(if startswith("\"") then .[1:-1] else . end)}]|from_entries)|del(.info)' | \
  yq -P

Handling more than strings

The same scan doesn’t know that "98.5" should be a number or "true" should be a boolean. Add the type coercion into the value branch:

value: .[1]|(
  if startswith("\"") then .[1:-1]
  elif . == "true" then true
  elif . == "false" then false
  elif test("^[0-9]+$") then tonumber
  elif test("^[0-9]+\\.[0-9]+$") then tonumber
  else .
  end
)

And it works the same way one level down, inside an array, by mapping the same expression over .members:

1
2
3
4
yq -ojson input.yml | \
  jq '.members |= map(.+(.info|[scan("(?<key>[^ =]+)=(?<value>\"[^\"]+\"|[^\" ]+)") | \
  {key: .[0], value: .[1]|(if startswith("\"") then .[1:-1] else . end)}]|from_entries)|del(.info))' | \
  yq -P

Notes from using this on real data

For anything beyond a one-off, put the jq filter in its own file rather than inlining it in a shell pipeline. It’s easier to read, and you can give it a name:

# transform.jq
def process_info:
  [scan("(?<key>[^ =]+)=(?<value>\"[^\"]+\"|[^\" ]+)") |
  {key: .[0], value: .[1]|(if startswith("\"") then .[1:-1] else . end)}] |
  from_entries;

.+(.info|process_info)|del(.info)

Validate the input before trusting the pipeline on it (yq eval 'type' input.yml), and if a large file makes debugging the pipeline painful, break it apart with tee so you can see the JSON at each stage rather than guessing where it went wrong.

This post is licensed under CC BY 4.0 by the author.