This content originally appeared on DEV Community and was authored by João Paulo Abreu
Keyword lists are like restaurant order forms where each field has a clear label and you can repeat certain items when it makes sense. Unlike a table setting where each position holds one specific item, an order form lets you write "drink: water, appetizer: salad, drink: soda" - keeping everything in order and allowing duplicates when needed. Think of [timeout: 5000, retry: 3, retry: 5]
as a configuration form where some options can be specified multiple times, each with its own meaning and context. While this flexibility makes keyword lists perfect for function options and small configurations, it also makes them quite different from maps in both behavior and use cases. In this comprehensive article, we'll explore how keyword lists work, when they're the perfect choice for your data, and the patterns that make them indispensable in Elixir's ecosystem.
Note: The examples in this article use Elixir 1.18.4. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Understanding Keyword Lists
- Creating and Manipulating Keyword Lists
- Function Options and Configuration Patterns
- Working with Duplicate Keys
- Pattern Matching with Keyword Lists
- Keyword Lists vs Maps
- Best Practices
- Conclusion
- Further Reading
- Next Steps
Introduction
Keyword lists are Elixir's specialized data structure for handling small collections of key-value pairs where order matters and duplicate keys provide semantic value. They serve as the backbone for function options, configuration systems, and DSL parameters throughout the Elixir ecosystem.
What makes keyword lists special:
- Ordered collection: Keys maintain their insertion order
- Duplicate keys allowed: Same key can appear multiple times
- Atom keys only: Keys must be atoms (not strings or other types)
-
Concise syntax: Clean
[key: value]
notation - Function-friendly: Perfect for optional parameters
- DSL integration: Essential for domain-specific languages
Think of keyword lists as configuration forms where each setting has a name:
- Function options:
[timeout: 5000, retries: 3, async: true]
- Query parameters:
[where: "active = true", limit: 10, order: "name"]
- Route options:
[as: :user_profile, only: [:show, :edit]]
Keyword lists excel when you need to:
- Pass options to functions with readable names
- Maintain the order of configuration items
- Allow certain options to be specified multiple times
- Create clean, readable APIs for your modules
- Build domain-specific languages and configuration systems
Let's explore how they work and why they're indispensable!
Understanding Keyword Lists
The Internal Structure
Keyword lists are actually lists of two-element tuples where the first element is always an atom:
# These are equivalent:
options_short = [timeout: 5000, retry: 3]
options_verbose = [{:timeout, 5000}, {:retry, 3}]
# Verify they're the same
options_short == options_verbose # true
# Under the hood, it's just a list
[timeout: 5000, retry: 3] |> is_list() # true
Testing in IEx:
iex> [timeout: 5000, retry: 3]
[timeout: 5000, retry: 3]
iex> [{:timeout, 5000}, {:retry, 3}]
[timeout: 5000, retry: 3]
iex> [timeout: 5000, retry: 3] == [{:timeout, 5000}, {:retry, 3}]
true
iex> is_list([timeout: 5000, retry: 3])
true
Key Characteristics
config = [host: "localhost", port: 4000, host: "remote", ssl: true]
# Order is preserved
Keyword.keys(config) # [:host, :port, :host, :ssl]
# Duplicate keys are allowed
Keyword.get_values(config, :host) # ["localhost", "remote"]
# First occurrence is returned by default
Keyword.get(config, :host) # "localhost"
# Only atoms can be keys in keyword lists
# [{1, "value"}] # Valid syntax but not a keyword list (integer key)
# [{"key", "value"}] # Valid syntax but not a keyword list (string key)
Testing in IEx:
iex> config = [host: "localhost", port: 4000, host: "remote", ssl: true]
[host: "localhost", port: 4000, host: "remote", ssl: true]
iex> Keyword.keys(config)
[:host, :port, :host, :ssl]
iex> Keyword.get_values(config, :host)
["localhost", "remote"]
iex> Keyword.get(config, :host)
"localhost"
Syntax Variations
# Standard syntax (recommended)
options = [timeout: 5000, async: true]
# Verbose tuple syntax
options_verbose = [{:timeout, 5000}, {:async, true}]
# Mixed syntax (keyword lists must come last)
mixed = [{:timeout, 5000}, async: true]
# With variables as keys
key = :retry
options_with_var = [{key, 3}, timeout: 5000] # [retry: 3, timeout: 5000]
# Multi-line for readability
long_config = [
host: "api.example.com",
port: 443,
timeout: 10_000,
retries: 5,
ssl: true
]
Testing in IEx:
iex> key = :retry
:retry
iex> options = [{key, 3}, timeout: 5000]
[retry: 3, timeout: 5000]
iex> Keyword.keyword?(options)
true
iex> Keyword.keyword?(%{retry: 3, timeout: 5000})
false
Creating and Manipulating Keyword Lists
Creation Methods
# Literal syntax (most common)
config = [host: "localhost", port: 4000, ssl: true]
# From tuples
from_tuples = Keyword.new([{:timeout, 5000}, {:retries, 3}])
# From maps (loses duplicate key capability)
from_map = Keyword.new(%{timeout: 5000, retries: 3})
# From other enumerables
from_list = Keyword.new([timeout: 5000, retries: 3])
# Empty keyword list
empty = Keyword.new() # []
# Building incrementally
incremental = []
|> Keyword.put(:host, "localhost")
|> Keyword.put(:port, 4000)
|> Keyword.put(:ssl, true)
Testing in IEx:
iex> Keyword.new([{:timeout, 5000}, {:retries, 3}])
[timeout: 5000, retries: 3]
iex> Keyword.new(%{timeout: 5000, retries: 3})
[timeout: 5000, retries: 3]
iex> [] |> Keyword.put(:host, "localhost") |> Keyword.put(:port, 4000)
[port: 4000, host: "localhost"]
Basic Operations
options = [timeout: 5000, retries: 3, host: "localhost"]
# Get values
timeout = Keyword.get(options, :timeout) # 5000
missing = Keyword.get(options, :missing, "N/A") # "N/A"
port = Keyword.get(options, :port, 4000) # 4000 (default)
# Check for keys
has_timeout = Keyword.has_key?(options, :timeout) # true
has_port = Keyword.has_key?(options, :port) # false
# Get all keys and values
keys = Keyword.keys(options) # [:timeout, :retries, :host]
values = Keyword.values(options) # [5000, 3, "localhost"]
# Check if it's a keyword list and get length
is_keyword = Keyword.keyword?(options) # true
length = length(options) # 3
Testing in IEx:
iex> options = [timeout: 5000, retries: 3, host: "localhost"]
[timeout: 5000, retries: 3, host: "localhost"]
iex> Keyword.get(options, :timeout)
5000
iex> Keyword.get(options, :port, 4000)
4000
iex> Keyword.has_key?(options, :timeout)
true
iex> Keyword.keys(options)
[:timeout, :retries, :host]
Updating and Modifying
original = [timeout: 5000, retries: 3]
# Add new key-value pair
with_ssl = Keyword.put(original, :ssl, true)
# [ssl: true, timeout: 5000, retries: 3]
# Update existing key (replaces first occurrence)
updated_timeout = Keyword.put(original, :timeout, 8000)
# [timeout: 8000, retries: 3]
# Add only if key doesn't exist
maybe_added = Keyword.put_new(original, :host, "localhost")
# [host: "localhost", timeout: 5000, retries: 3]
wont_change = Keyword.put_new(original, :timeout, 8000)
# [timeout: 5000, retries: 3] (timeout already exists)
# Delete keys
without_retries = Keyword.delete(original, :retries)
# [timeout: 5000]
# Merge keyword lists
defaults = [timeout: 1000, retries: 1, ssl: false]
user_config = [timeout: 5000, host: "remote"]
merged = Keyword.merge(defaults, user_config)
# [timeout: 5000, retries: 1, ssl: false, host: "remote"]
Testing in IEx:
iex> original = [timeout: 5000, retries: 3]
[timeout: 5000, retries: 3]
iex> Keyword.put(original, :ssl, true)
[ssl: true, timeout: 5000, retries: 3]
iex> Keyword.put_new(original, :timeout, 8000)
[timeout: 5000, retries: 3]
iex> Keyword.put_new(original, :host, "localhost")
[host: "localhost", timeout: 5000, retries: 3]
iex> defaults = [timeout: 1000, retries: 1, ssl: false]
[timeout: 1000, retries: 1, ssl: false]
iex> user_config = [timeout: 5000, host: "remote"]
[timeout: 5000, host: "remote"]
iex> Keyword.merge(defaults, user_config)
[retries: 1, ssl: false, timeout: 5000, host: "remote"]
Conversion Operations
# Keyword list to map
kw_list = [name: "Alice", age: 30, role: :admin]
as_map = Enum.into(kw_list, %{})
# %{age: 30, name: "Alice", role: :admin}
# Map to keyword list
map = %{timeout: 5000, retries: 3}
as_keyword = Enum.into(map, [])
# [timeout: 5000, retries: 3] (order may vary)
# Filter keyword list
config = [timeout: 5000, retries: 3, debug: true, verbose: false]
only_booleans = Keyword.take(config, [:debug, :verbose])
# [debug: true, verbose: false]
without_debug = Keyword.drop(config, [:debug, :verbose])
# [timeout: 5000, retries: 3]
Testing in IEx:
iex> kw_list = [name: "Alice", age: 30, role: :admin]
[name: "Alice", age: 30, role: :admin]
iex> Enum.into(kw_list, %{})
%{age: 30, name: "Alice", role: :admin}
iex> map = %{timeout: 5000, retries: 3}
%{retries: 3, timeout: 5000}
iex> Enum.into(map, [])
[timeout: 5000, retries: 3]
iex> config = [timeout: 5000, retries: 3, debug: true, verbose: false]
[timeout: 5000, retries: 3, debug: true, verbose: false]
iex> Keyword.take(config, [:debug, :verbose])
[debug: true, verbose: false]
Function Options and Configuration Patterns
The Idiomatic Elixir Pattern
Functions that accept options typically use keyword lists as the last parameter:
defmodule HTTPClient do
# Function with default options
def get(url, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)
retries = Keyword.get(opts, :retries, 3)
_headers = Keyword.get(opts, :headers, [])
"Requesting #{url} with timeout: #{timeout}ms, retries: #{retries}"
end
# More complex option processing
def post(url, _body, opts \\ []) do
config = build_config(opts)
"POST to #{url} with config: #{inspect(config)}"
end
defp build_config(opts) do
%{
timeout: Keyword.get(opts, :timeout, 5000),
retries: Keyword.get(opts, :retries, 3),
headers: Keyword.get(opts, :headers, []),
follow_redirects: Keyword.get(opts, :follow_redirects, true),
ssl_verify: Keyword.get(opts, :ssl_verify, true)
}
end
end
Testing in IEx:
iex> HTTPClient.get("https://api.example.com")
"Requesting https://api.example.com with timeout: 5000ms, retries: 3"
iex> HTTPClient.get("https://api.example.com", timeout: 1000, retries: 5)
"Requesting https://api.example.com with timeout: 1000ms, retries: 5"
iex> HTTPClient.post("https://api.example.com", "data", ssl_verify: false)
"POST to https://api.example.com with config: %{follow_redirects: true, headers: [], retries: 3, ssl_verify: false, timeout: 5000}"
Option Validation Patterns
defmodule SafeProcessor do
@valid_options [:timeout, :retries, :async, :callback]
@required_options [:timeout]
def process(_data, opts \\ []) do
with :ok <- validate_options(opts),
config <- build_config(opts) do
{:ok, "Processing with config: #{inspect(config)}"}
else
{:error, reason} -> {:error, reason}
end
end
defp validate_options(opts) do
case validate_required(opts) do
:ok -> validate_allowed(opts)
error -> error
end
end
defp validate_required(opts) do
missing = @required_options -- Keyword.keys(opts)
if missing == [], do: :ok, else: {:error, {:missing_options, missing}}
end
defp validate_allowed(opts) do
invalid = Keyword.keys(opts) -- @valid_options
if invalid == [], do: :ok, else: {:error, {:invalid_options, invalid}}
end
defp build_config(opts) do
%{
timeout: Keyword.get(opts, :timeout),
retries: Keyword.get(opts, :retries, 3),
async: Keyword.get(opts, :async, false),
callback: Keyword.get(opts, :callback)
}
end
end
Testing in IEx:
iex> SafeProcessor.process("data", timeout: 5000, retries: 2)
{:ok, "Processing with config: %{async: false, callback: nil, retries: 2, timeout: 5000}"}
iex> SafeProcessor.process("data", [])
{:error, {:missing_options, [:timeout]}}
iex> SafeProcessor.process("data", timeout: 5000, invalid_option: true)
{:error, {:invalid_options, [:invalid_option]}}
Configuration Management
defmodule AppConfig do
@default_config [
host: "localhost",
port: 4000,
pool_size: 10,
timeout: 15_000,
ssl: false,
retries: 3
]
def load(env_config \\ []) do
@default_config
|> Keyword.merge(env_config)
|> validate_config()
end
def get_database_config(config) do
config
|> Keyword.take([:host, :port, :pool_size, :ssl])
|> add_database_specific_options()
end
def get_http_config(config) do
config
|> Keyword.take([:host, :port, :timeout, :retries, :ssl])
end
defp add_database_specific_options(config) do
config
|> Keyword.put_new(:username, "postgres")
|> Keyword.put_new(:password, "")
|> Keyword.put_new(:database, "myapp_dev")
end
defp validate_config(config) do
cond do
config[:port] < 1 or config[:port] > 65535 ->
{:error, "Invalid port number"}
config[:pool_size] < 1 ->
{:error, "Pool size must be positive"}
true ->
{:ok, config}
end
end
end
Testing in IEx:
iex> AppConfig.load()
{:ok, [host: "localhost", port: 4000, pool_size: 10, timeout: 15000, ssl: false, retries: 3]}
iex> AppConfig.load(port: 8080, ssl: true)
{:ok, [host: "localhost", port: 8080, pool_size: 10, timeout: 15000, ssl: true, retries: 3]}
iex> {:ok, config} = AppConfig.load()
{:ok, [host: "localhost", port: 4000, pool_size: 10, timeout: 15000, ssl: false, retries: 3]}
iex> AppConfig.get_database_config(config)
[host: "localhost", port: 4000, pool_size: 10, ssl: false, username: "postgres", password: "", database: "myapp_dev"]
Documentation Patterns
defmodule DocumentedModule do
@doc """
Processes data with configurable options.
## Options
* `:timeout` - Request timeout in milliseconds (default: 5000)
* `:retries` - Number of retry attempts (default: 3)
* `:async` - Process asynchronously (default: false)
* `:callback` - Function called on completion (default: nil)
* `:format` - Output format, either `:json` or `:text` (default: `:json`)
## Examples
iex> DocumentedModule.process("data")
{:ok, "Processing data with default options"}
iex> DocumentedModule.process("data", timeout: 1000, async: true)
{:ok, "Processing data asynchronously with 1000ms timeout"}
"""
def process(data, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)
async = Keyword.get(opts, :async, false)
async_text = if async, do: "asynchronously ", else: ""
{:ok, "Processing #{data} #{async_text}with #{timeout}ms timeout"}
end
end
Testing in IEx:
iex> DocumentedModule.process("data")
{:ok, "Processing data with 5000ms timeout"}
iex> DocumentedModule.process("data", timeout: 1000, async: true)
{:ok, "Processing data asynchronously with 1000ms timeout"}
Working with Duplicate Keys
Understanding Duplicate Key Behavior
config = [env: :dev, timeout: 5000, env: :prod, retry: 3, env: :test]
# get/2 returns the FIRST occurrence
primary_env = Keyword.get(config, :env) # :dev
# get_values/2 returns ALL occurrences
all_envs = Keyword.get_values(config, :env) # [:dev, :prod, :test]
# has_key?/2 returns true if ANY occurrence exists
has_env = Keyword.has_key?(config, :env) # true
# delete/2 removes ALL occurrences
no_envs = Keyword.delete(config, :env)
# [timeout: 5000, retry: 3]
Testing in IEx:
iex> config = [env: :dev, timeout: 5000, env: :prod, retry: 3, env: :test]
[env: :dev, timeout: 5000, env: :prod, retry: 3, env: :test]
iex> Keyword.get(config, :env)
:dev
iex> Keyword.get_values(config, :env)
[:dev, :prod, :test]
iex> Keyword.delete(config, :env)
[timeout: 5000, retry: 3]
Practical Use Cases for Duplicates
defmodule PlugPipeline do
# Multiple middleware configurations
def build_pipeline(opts) do
middleware_configs = Keyword.get_values(opts, :middleware)
auth_configs = Keyword.get_values(opts, :auth)
%{
middleware: middleware_configs,
auth: auth_configs,
timeout: Keyword.get(opts, :timeout, 30_000)
}
end
def example_usage do
options = [
middleware: {:cors, origins: ["localhost"]},
middleware: {:rate_limit, max: 100},
middleware: {:logging, level: :info},
auth: {:token, header: "Authorization"},
auth: {:session, store: :cookie},
timeout: 5000
]
build_pipeline(options)
end
end
Testing in IEx:
iex> PlugPipeline.example_usage()
%{
auth: [token: [header: "Authorization"], session: [store: :cookie]],
middleware: [
cors: [origins: ["localhost"]],
rate_limit: [max: 100],
logging: [level: :info]
],
timeout: 5000
}
Advanced Duplicate Key Patterns
defmodule QueryBuilder do
def build_query(clauses) do
%{
select: build_select_clause(clauses),
where: build_where_clauses(clauses),
join: build_join_clauses(clauses),
order: build_order_clauses(clauses)
}
end
defp build_select_clause(clauses) do
case Keyword.get_values(clauses, :select) do
[] -> "*"
fields -> Enum.join(fields, ", ")
end
end
defp build_where_clauses(clauses) do
clauses
|> Keyword.get_values(:where)
|> Enum.join(" AND ")
end
defp build_join_clauses(clauses) do
Keyword.get_values(clauses, :join)
end
defp build_order_clauses(clauses) do
clauses
|> Keyword.get_values(:order)
|> Enum.join(", ")
end
def example_query do
clauses = [
select: "name",
select: "email",
select: "age",
where: "active = true",
where: "age >= 18",
join: "LEFT JOIN profiles ON users.id = profiles.user_id",
order: "name ASC",
order: "created_at DESC"
]
build_query(clauses)
end
end
Testing in IEx:
iex> QueryBuilder.example_query()
%{
join: ["LEFT JOIN profiles ON users.id = profiles.user_id"],
order: "name ASC, created_at DESC",
select: "name, email, age",
where: "active = true AND age >= 18"
}
Handling Duplicates Safely
defmodule DuplicateHandler do
# Get first occurrence with fallback
def get_first_or_default(keyword_list, key, default) do
case Keyword.get_values(keyword_list, key) do
[] -> default
[first | _] -> first
end
end
# Get last occurrence
def get_last(keyword_list, key) do
keyword_list
|> Keyword.get_values(key)
|> List.last()
end
# Remove specific occurrence by value
def delete_value(keyword_list, key, value) do
Enum.reject(keyword_list, fn {k, v} -> k == key and v == value end)
end
# Replace all occurrences with single value
def replace_all(keyword_list, key, new_value) do
keyword_list
|> Keyword.delete(key)
|> Keyword.put(key, new_value)
end
end
Testing in IEx:
iex> config = [env: :dev, timeout: 5000, env: :prod, env: :test]
[env: :dev, timeout: 5000, env: :prod, env: :test]
iex> DuplicateHandler.get_last(config, :env)
:test
iex> DuplicateHandler.delete_value(config, :env, :prod)
[env: :dev, timeout: 5000, env: :test]
iex> DuplicateHandler.replace_all(config, :env, :staging)
[env: :staging, timeout: 5000]
Pattern Matching with Keyword Lists
The Official Warning
Important: The Elixir documentation explicitly advises against pattern matching directly on keyword lists. Here's why and what to do instead:
Why Avoid Direct Pattern Matching
defmodule AntiPatterns do
# DON'T DO THIS - Fragile and order-dependent
def bad_pattern_match([timeout: t, retries: r]) do
"Timeout: #{t}, Retries: #{r}"
end
# This function will fail if:
# - Keys are in different order: [retries: 3, timeout: 5000]
# - Extra keys are present: [timeout: 5000, retries: 3, ssl: true]
# - Keys are missing: [timeout: 5000]
# DON'T DO THIS EITHER - Still fragile
def bad_partial_match([timeout: t | _rest]) do
"Timeout: #{t}"
end
# Fails if timeout is not the first key
end
Safe Alternatives Using Keyword Module
defmodule SafePatterns do
# GOOD - Use Keyword.get/3 for extraction
def safe_extraction(options) do
timeout = Keyword.get(options, :timeout, 5000)
retries = Keyword.get(options, :retries, 3)
"Timeout: #{timeout}, Retries: #{retries}"
end
# GOOD - Validate structure first
def process_with_validation(options) do
with {:ok, timeout} <- fetch_timeout(options),
{:ok, retries} <- fetch_retries(options) do
{:ok, "Processing with timeout: #{timeout}, retries: #{retries}"}
end
end
defp fetch_timeout(options) do
case Keyword.fetch(options, :timeout) do
{:ok, timeout} when is_integer(timeout) and timeout > 0 -> {:ok, timeout}
{:ok, _} -> {:error, :invalid_timeout}
:error -> {:error, :missing_timeout}
end
end
defp fetch_retries(options) do
case Keyword.fetch(options, :retries) do
{:ok, retries} when is_integer(retries) and retries >= 0 -> {:ok, retries}
{:ok, _} -> {:error, :invalid_retries}
:error -> {:ok, 3} # Default value
end
end
# GOOD - Pattern match on known structure after validation
def safe_pattern_match(options) when is_list(options) do
case Keyword.keyword?(options) do
true -> extract_safely(options)
false -> {:error, :not_keyword_list}
end
end
defp extract_safely(options) do
host = Keyword.get(options, :host, "localhost")
port = Keyword.get(options, :port, 4000)
{:ok, "Connecting to #{host}:#{port}"}
end
end
Testing in IEx:
iex> good_options = [retries: 3, timeout: 5000, ssl: true]
[retries: 3, timeout: 5000, ssl: true]
iex> SafePatterns.safe_extraction(good_options)
"Timeout: 5000, Retries: 3"
iex> SafePatterns.process_with_validation(good_options)
{:ok, "Processing with timeout: 5000, retries: 3"}
iex> bad_options = [timeout: -1000, retries: 3]
[timeout: -1000, retries: 3]
iex> SafePatterns.process_with_validation(bad_options)
{:error, :invalid_timeout}
When Pattern Matching Can Work
There are limited scenarios where pattern matching on keyword lists is acceptable:
defmodule LimitedPatternMatching do
# ACCEPTABLE - Matching on empty list
def handle_options([]), do: "No options provided"
# ACCEPTABLE - Matching specific known structures in controlled contexts
def handle_simple_config([debug: true]), do: "Debug mode enabled"
def handle_simple_config([debug: false]), do: "Debug mode disabled"
def handle_simple_config(other), do: handle_complex_config(other)
# PREFERRED - Always have a catch-all that uses Keyword functions
defp handle_complex_config(options) do
debug = Keyword.get(options, :debug, false)
"Debug mode: #{debug}, other options: #{inspect(Keyword.delete(options, :debug))}"
end
end
Testing in IEx:
iex> LimitedPatternMatching.handle_options([])
"No options provided"
iex> LimitedPatternMatching.handle_simple_config([debug: true])
"Debug mode enabled"
iex> LimitedPatternMatching.handle_simple_config([debug: true, timeout: 5000])
"Debug mode: true, other options: [timeout: 5000]"
Safe Extraction Patterns
defmodule ExtractionPatterns do
# Extract with defaults and validation
def extract_database_config(options) do
%{
host: Keyword.get(options, :host, "localhost"),
port: validate_port(Keyword.get(options, :port, 5432)),
database: Keyword.fetch!(options, :database),
username: Keyword.get(options, :username, "postgres"),
password: Keyword.get(options, :password, ""),
ssl: Keyword.get(options, :ssl, false)
}
rescue
KeyError -> {:error, :missing_database_name}
end
# Multiple option sets handling
def extract_environments(options) do
options
|> Keyword.get_values(:env)
|> case do
[] -> [:dev] # Default environment
envs -> envs
end
end
# Conditional extraction
def extract_auth_config(options) do
case {Keyword.has_key?(options, :username), Keyword.has_key?(options, :token)} do
{true, false} ->
{:basic_auth, Keyword.get(options, :username), Keyword.get(options, :password, "")}
{false, true} ->
{:token_auth, Keyword.get(options, :token)}
{false, false} ->
:no_auth
{true, true} ->
{:error, :conflicting_auth_methods}
end
end
defp validate_port(port) when is_integer(port) and port > 0 and port <= 65535, do: port
defp validate_port(_), do: raise(ArgumentError, "Invalid port number")
end
Testing in IEx:
iex> db_options = [host: "db.example.com", database: "myapp", port: 5432]
[host: "db.example.com", database: "myapp", port: 5432]
iex> ExtractionPatterns.extract_database_config(db_options)
%{database: "myapp", host: "db.example.com", password: "", port: 5432, ssl: false, username: "postgres"}
iex> auth_options = [username: "admin", password: "secret"]
[username: "admin", password: "secret"]
iex> ExtractionPatterns.extract_auth_config(auth_options)
{:basic_auth, "admin", "secret"}
iex> token_options = [token: "abc123"]
[token: "abc123"]
iex> ExtractionPatterns.extract_auth_config(token_options)
{:token_auth, "abc123"}
Keyword Lists vs Maps
Decision Guide
Understanding when to use keyword lists versus maps is crucial for writing idiomatic Elixir:
Use Keyword Lists When:
# 1. Function options (the primary use case)
def api_call(endpoint, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)
headers = Keyword.get(opts, :headers, [])
# Process with options...
end
# 2. Small configuration (typically < 50 keys)
config = [
host: "localhost",
port: 4000,
ssl: false,
retries: 3
]
# 3. DSL parameters where order matters
route_options = [as: :user_profile, only: [:show, :edit], except: [:delete]]
# 4. Duplicate keys provide semantic meaning
middleware_stack = [
middleware: {:cors, origins: ["*"]},
middleware: {:auth, required: true},
middleware: {:logging, level: :info}
]
# 5. Interfacing with libraries that expect keyword lists
# Many Elixir libraries use keyword lists for options
Use Maps When:
# 1. Large datasets (50+ key-value pairs)
large_config = %{
database_url: "...",
redis_url: "...",
# ... 50+ more configuration items
}
# 2. Large datasets and complex data structures
cache = %{
"user:123" => %{name: "Alice"},
"user:456" => %{name: "Bob"},
# ... potentially thousands of entries
}
# 3. Data modeling and structured records
user = %{
id: 123,
name: "Alice",
email: "alice@example.com",
created_at: ~U[2024-01-15 10:00:00Z]
}
# 4. Pattern matching requirements
def handle_response(%{status: 200, body: body}), do: {:ok, body}
def handle_response(%{status: 404}), do: {:error, :not_found}
# 5. Unique keys required
settings = %{theme: "dark", language: "en"} # No duplicate keys allowed
Conversion Strategies
defmodule ConversionStrategies do
# Convert keyword list to map when it becomes large
def optimize_for_lookups(options) when length(options) > 50 do
Enum.into(options, %{})
end
def optimize_for_lookups(options), do: options
# Maintain keyword list for small, option-like data
def ensure_keyword_list(data) when is_map(data) and map_size(data) <= 20 do
Enum.into(data, [])
end
def ensure_keyword_list(data) when is_list(data), do: data
def ensure_keyword_list(data), do: data # Keep as-is for other types
# Smart conversion based on usage pattern
def convert_for_usage(data, :large_dataset) when is_list(data) do
Enum.into(data, %{})
end
def convert_for_usage(data, :function_options) when is_map(data) do
Enum.into(data, [])
end
def convert_for_usage(data, _usage), do: data
# Preserve duplicates when converting back to keyword list
def map_to_keyword_preserving_order(map, key_order) do
Enum.map(key_order, fn key -> {key, Map.get(map, key)} end)
end
end
Testing in IEx:
iex> small_options = [timeout: 5000, retries: 3, ssl: true]
[timeout: 5000, retries: 3, ssl: true]
iex> ConversionStrategies.optimize_for_lookups(small_options)
[timeout: 5000, retries: 3, ssl: true]
iex> large_options = Enum.map(1..60, &{:"key#{&1}", &1})
[key1: 1, key2: 2, key3: 3, key4: 4, key5: 5, ...]
iex> optimized = ConversionStrategies.optimize_for_lookups(large_options)
%{key1: 1, key2: 2, key3: 3, key4: 4, key5: 5, ...}
iex> is_map(optimized)
true
Hybrid Approaches
defmodule HybridPatterns do
# Use both structures for different purposes
defstruct options: [], cache: %{}
def new(options \\ []) do
%__MODULE__{options: options, cache: build_cache(options)}
end
# Keep options as keyword list for API compatibility
def get_option(%__MODULE__{options: options}, key, default) do
Keyword.get(options, key, default)
end
# Use map for internal lookups
defp build_cache(options) do
options
|> Enum.take(20) # Cache only first 20 options
|> Enum.into(%{})
end
# Extract frequently accessed options to map, keep others as keyword list
def partition_options(options, frequent_keys) do
{frequent, others} = Enum.split_with(options, fn {key, _} ->
key in frequent_keys
end)
{Enum.into(frequent, %{}), others}
end
end
Testing in IEx:
iex> config = HybridPatterns.new([timeout: 5000, retries: 3, ssl: true])
%HybridPatterns{cache: %{retries: 3, ssl: true, timeout: 5000}, options: [timeout: 5000, retries: 3, ssl: true]}
iex> HybridPatterns.get_option(config, :timeout, 1000)
5000
iex> options = [timeout: 5000, retries: 3, host: "localhost", ssl: true, debug: false]
[timeout: 5000, retries: 3, host: "localhost", ssl: true, debug: false]
iex> HybridPatterns.partition_options(options, [:timeout, :retries])
{%{retries: 3, timeout: 5000}, [host: "localhost", ssl: true, debug: false]}
Best Practices
Do's and Don'ts
✅ DO: Use keyword lists for function options
# Good - idiomatic Elixir pattern
def api_call(endpoint, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)
retries = Keyword.get(opts, :retries, 3)
# Process with options
end
# Usage
api_call("/users", timeout: 1000, retries: 5)
✅ DO: Keep keyword lists small (< 50 keys)
# Good - small configuration
config = [
host: "localhost",
port: 4000,
ssl: true,
pool_size: 10
]
✅ DO: Use Keyword module functions for access
# Good - safe and flexible
def process_config(config) do
host = Keyword.get(config, :host, "localhost")
port = Keyword.get(config, :port, 4000)
ssl = Keyword.get(config, :ssl, false)
%{host: host, port: port, ssl: ssl}
end
✅ DO: Document your options clearly
@doc """
## Options
* `:timeout` - Request timeout in milliseconds (default: 5000)
* `:retries` - Number of retry attempts (default: 3)
* `:ssl` - Enable SSL connection (default: false)
"""
def connect(url, opts \\ []) do
# Implementation
end
❌ DON'T: Use pattern matching directly on keyword lists
# Bad - fragile and order-dependent
def bad_pattern([timeout: t, retries: r]), do: {t, r}
# Good - safe extraction
def good_extraction(opts) do
timeout = Keyword.get(opts, :timeout, 5000)
retries = Keyword.get(opts, :retries, 3)
{timeout, retries}
end
❌ DON'T: Use keyword lists for large datasets
# Bad - not suitable for large datasets
large_config = Enum.map(1..200, &{:"setting#{&1}", &1})
# Good - use maps for large collections
large_config = 1..200 |> Enum.map(&{:"setting#{&1}", &1}) |> Enum.into(%{})
Conclusion
Keyword lists are a specialized but essential data structure in Elixir that serve as the foundation for clean, readable APIs and configuration systems. In this comprehensive article, we've explored:
- How keyword lists work internally as lists of two-element tuples
- Creating and manipulating keyword lists with the Keyword module
- The idiomatic pattern of using keyword lists for function options
- Working safely with duplicate keys and understanding their semantic value
- Why pattern matching should be avoided and what to use instead
- When to choose each data structure for optimal results
- Best practices for maintainable and performant code
Key takeaways:
- Perfect for function options: Keyword lists are the idiomatic choice for optional parameters
- Keep them small: Most suitable for fewer than 50 key-value pairs
- Order matters: Unlike maps, keyword lists preserve insertion order
- Duplicates allowed: Same keys can appear multiple times with semantic meaning
-
Use Keyword module: Always prefer
Keyword.get/3
over pattern matching - Consider conversion: Convert to maps for large datasets or complex data modeling
- Document thoroughly: Clear option documentation improves API usability
Keyword lists demonstrate Elixir's philosophy of having specialized tools for specific jobs. While maps excel at general key-value storage and data modeling, keyword lists shine in their specific niche of function options, small configurations, and DSL parameters. Their unique characteristics—ordered keys, duplicate support, and clean syntax—make them irreplaceable for creating readable, maintainable Elixir code.
Understanding when and how to use keyword lists effectively will help you write more idiomatic Elixir code that integrates seamlessly with the broader ecosystem, from Phoenix web applications to configuration management systems.
Further Reading
- Elixir Official Documentation - Keyword
- Elixir School - Keyword Lists
- Programming Elixir by Dave Thomas - Collections Chapter
Next Steps
With a solid understanding of keyword lists, you're ready to explore Structs in Elixir. Structs provide a way to define custom data types with fixed fields, compile-time guarantees, and pattern matching capabilities that complement the flexible nature of maps and keyword lists.
In the next article, we'll explore:
- Defining and creating custom structs
- Struct vs map - when to use each approach
- Pattern matching with structs for type safety
- Updating structs with immutable operations
Structs represent the next level of data modeling in Elixir, providing the structure and type safety that make large applications maintainable and robust!
This content originally appeared on DEV Community and was authored by João Paulo Abreu

João Paulo Abreu | Sciencx (2025-08-09T12:35:16+00:00) Learning Elixir: Keyword Lists. Retrieved from https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.