Content is user-generated and unverified.

CMake as a scripting language: the complete guide to cmake -P

Most developers know CMake as a build system generator, but CMake is also a full scripting language you can run from the command line with cmake -P script.cmake. No compilers, no build trees, no Makefiles — just a portable, cross-platform scripting engine that ships with every CMake installation. This tutorial teaches you the CMake language from scratch, purely for scripting. You will learn variables, control flow, string and list manipulation, file I/O, process execution, functions, path handling, and the pitfalls that trip up even experienced users. Every concept includes runnable examples you can paste into a .cmake file and execute immediately.


1. What cmake -P does and doesn't do

Running cmake -P myscript.cmake tells CMake to execute a file as a script. No build system is generated, no cache file is created, and no configure step occurs. CMake reads the file top-to-bottom and runs each command, then exits — just like python script.py or bash script.sh.

The invocation syntax is:

bash
cmake [-D<var>=<value>]... -P <script-file> [-- <args>...]

Create a file called hello.cmake:

cmake
message("Hello from CMake scripting!")

Run it:

bash
cmake -P hello.cmake

Output: Hello from CMake scripting!

What you can use

Script mode gives you the entire CMake language: variables, conditionals, loops, functions, macros, string and list operations, file I/O, process execution, path manipulation, math, environment variable access, and the include() command for sourcing other .cmake files. You also get find_program(), find_file(), configure_file(), cmake_parse_arguments(), and the platform-detection variables WIN32, UNIX, and APPLE.

What you cannot use

Commands that define build targets or actions do not exist in script mode. This includes project(), add_executable(), add_library(), add_custom_target(), add_custom_command(), install(), target_link_libraries(), target_include_directories(), add_subdirectory(), add_test(), and enable_testing(). There is no compiler detection — variables like CMAKE_C_COMPILER or CMAKE_CXX_FLAGS are not set. There is no build directory vs. source directory distinction; CMAKE_SOURCE_DIR, CMAKE_BINARY_DIR, CMAKE_CURRENT_SOURCE_DIR, and CMAKE_CURRENT_BINARY_DIR are all set to the current working directory.

Think of script mode as CMake minus everything related to compiling code. It is, however, surprisingly capable for general-purpose scripting.


2. Variables, set(), and unset()

All values in CMake are strings. There are no integers, booleans, or arrays as distinct types. The set() command creates a variable, and unset() removes it.

cmake
set(NAME "Alice")
set(COUNT 42)           # still a string "42" internally
message("Hello, ${NAME}! Count is ${COUNT}.")

Output: Hello, Alice! Count is 42.

The ${VAR} syntax performs variable expansion — it replaces the reference with the variable's value. If a variable is not set, ${VAR} silently expands to an empty string.

cmake
message("Undefined: '${DOES_NOT_EXIST}'")
# Output: Undefined: ''

To remove a variable, use unset():

cmake
set(TEMP "something")
unset(TEMP)
message("TEMP is now: '${TEMP}'")   # empty

Variable names are case-sensitive. MyVar, myvar, and MYVAR are three different variables. The convention is UPPER_SNAKE_CASE. Avoid names starting with CMAKE_ or _CMAKE_ — those are reserved.

Multiple values create a list

When you pass multiple values to set(), CMake joins them with semicolons to form a list:

cmake
set(FRUITS apple banana cherry)
message("${FRUITS}")
# Output: apple;banana;cherry

This semicolon-separated string is how CMake represents lists. We will cover lists in detail later.


3. Quoting rules and argument types

CMake has three types of arguments, and understanding them is essential.

Quoted arguments: "..."

Surrounded by double quotes. Always treated as exactly one argument, regardless of spaces or semicolons inside. Variable references ${VAR} and escape sequences are evaluated.

cmake
set(MSG "Hello World")
message("${MSG}")          # one argument: Hello World
message("a;b;c")           # one argument: a;b;c (semicolons preserved)

Unquoted arguments

Not surrounded by any quoting. Variable references and escape sequences are evaluated. Critically, after expansion, the result is split on semicolons into multiple arguments. Spaces also separate unquoted arguments.

cmake
set(ITEMS "x;y;z")
message(${ITEMS})      # THREE arguments: x, y, z (concatenated by message: xyz)
message("${ITEMS}")    # ONE argument: x;y;z

Bracket arguments: [=[ ... ]=]

Surrounded by matching [=[ and ]=] brackets (the number of = signs can vary, including zero). No variable expansion or escape processing occurs. Everything inside is literal. Useful for large blocks of text or embedded code:

cmake
message([=[
This is a bracket argument.
No ${expansion} or \n escaping happens here.
Semicolons ; and quotes " are all literal.
]=])

Escape sequences

Inside quoted and unquoted arguments, these escapes work:

SequenceResult
\\Literal backslash
\"Literal double quote
\nNewline
\tTab
\rCarriage return
\;Literal semicolon (does not split into list elements)

4. The critical importance of quoting "${VAR}"

This is the single most important habit to develop in CMake scripting. When you expand a variable without quotes, two things can go wrong.

Problem 1 — empty variables vanish. If VAR is empty or undefined, unquoted ${VAR} produces zero arguments. The argument simply disappears from the command call:

cmake
set(VAR "")
# BAD: becomes if(STREQUAL "hello") — wrong number of arguments!
if(${VAR} STREQUAL "hello")
  message("match")
endif()

# GOOD: becomes if("" STREQUAL "hello") — correct
if("${VAR}" STREQUAL "hello")
  message("match")
endif()

Problem 2 — semicolons split into multiple arguments. If VAR contains semicolons, unquoted expansion turns one value into many:

cmake
set(PATH "/opt/a;/opt/b")
# Unquoted: message receives two arguments
# Quoted: message receives one argument preserving the semicolon

The rule: always write "${VAR}" unless you intentionally want list expansion. Quoting is free and prevents entire categories of bugs.


5. message() for output

The message() command is your primary output tool. Its first argument is an optional mode that controls where output goes and whether execution continues.

cmake
message("plain message")                    # goes to stderr, no prefix
message(STATUS "informational")             # goes to stdout, prefixed with "-- "
message(NOTICE "important notice")          # goes to stderr, no prefix (same as no mode)
message(WARNING "something looks wrong")    # stderr, shown with CMake Warning formatting
message(FATAL_ERROR "cannot continue")      # stderr, halts execution immediately

The most commonly used modes:

ModeDestinationPrefixEffect
(none) / NOTICEstderrnoneContinues
STATUSstdout-- Continues
WARNINGstderrCMake WarningContinues
FATAL_ERRORstderrCMake ErrorStops execution

When you provide multiple string arguments, message() concatenates them with no separator:

cmake
message(STATUS "Count: " 42 " items")
# Output: -- Count: 42 items

For most scripting, use STATUS for normal output and FATAL_ERROR for unrecoverable errors. You can also use VERBOSE, DEBUG, and TRACE modes whose visibility is controlled by the --log-level command-line flag.


6. Passing data into scripts with -D

You pass variables to a script using -D flags before the -P flag:

bash
cmake -DNAME=World -DCOUNT=5 -P greet.cmake

Inside greet.cmake, NAME and COUNT are normal variables:

cmake
message(STATUS "Hello, ${NAME}! Count = ${COUNT}")

To pass values with spaces, quote them in the shell:

bash
cmake -DGREETING="Good morning" -P greet.cmake

To pass a list, use semicolons:

bash
cmake -DFILES="a.txt;b.txt;c.txt" -P process.cmake

Important: the -D flags must come before -P. Anything after the script filename is treated as a positional argument, not a CMake variable definition.

Positional arguments with --

You can pass extra arguments after --, accessible via CMAKE_ARGC and CMAKE_ARGV<n>:

bash
cmake -P args.cmake -- hello world
cmake
# args.cmake
message("Total args: ${CMAKE_ARGC}")
# CMAKE_ARGV0 = path-to-cmake
# CMAKE_ARGV1 = -P
# CMAKE_ARGV2 = args.cmake
# CMAKE_ARGV3 = --
# CMAKE_ARGV4 = hello
# CMAKE_ARGV5 = world
foreach(i RANGE 0 ${CMAKE_ARGC})
    if(DEFINED CMAKE_ARGV${i})
        message("  ${i}: ${CMAKE_ARGV${i}}")
    endif()
endforeach()

Note that CMAKE_ARGV<n> includes all command-line arguments: the cmake binary path, -P, the script name, the -- separator, and then your arguments.


7. Environment variables

Read environment variables with $ENV{NAME} and set them with set(ENV{NAME} value):

cmake
message("Home directory: $ENV{HOME}")
message("PATH: $ENV{PATH}")

set(ENV{MY_CUSTOM_VAR} "hello")
message("Custom: $ENV{MY_CUSTOM_VAR}")

unset(ENV{MY_CUSTOM_VAR})

Changes to environment variables affect only the running CMake process. They are not written back to your shell and are not inherited by child processes launched via execute_process() unless you take extra steps. You can check whether an environment variable exists with if(DEFINED ENV{NAME}).


8. Predefined variables in script mode

Several variables are automatically available when running cmake -P:

VariableValue
CMAKE_SCRIPT_MODE_FILEFull path of the script file passed to -P. Does not change when include() processes other files.
CMAKE_CURRENT_LIST_FILEFull path of the file currently being processed. Changes when include() brings in other files.
CMAKE_CURRENT_LIST_DIRDirectory containing CMAKE_CURRENT_LIST_FILE. Updates dynamically with includes.
CMAKE_COMMANDAbsolute path to the cmake executable being used.
CMAKE_VERSIONCMake version string, e.g. 3.28.1.
WIN32TRUE on Windows
UNIXTRUE on Unix-like systems (including macOS)
APPLETRUE on macOS / iOS / other Apple platforms
CMAKE_ARGCNumber of command-line arguments (script mode only)
CMAKE_ARGV<n>Individual command-line arguments by index

A useful pattern is to locate files relative to the script:

cmake
set(DATA_DIR "${CMAKE_CURRENT_LIST_DIR}/data")
file(GLOB inputs "${DATA_DIR}/*.txt")

You can detect whether code is running in script mode by checking CMAKE_SCRIPT_MODE_FILE:

cmake
if(CMAKE_SCRIPT_MODE_FILE)
    message("Running as a script")
endif()

9. Control flow

if / elseif / else / endif

cmake
set(X 10)
if(X GREATER 20)
    message("big")
elseif(X GREATER 5)
    message("medium")
else()
    message("small")
endif()
# Output: medium

Conditions are not C-style expressions — they use keyword operators. Here are the most useful ones:

Logical operators:

cmake
if(NOT condition)
if(cond1 AND cond2)
if(cond1 OR cond2)
if((cond1 OR cond2) AND cond3)    # parentheses for grouping

Truthiness: if(VAR) is true when VAR is defined and its value is not one of the false constants: 0, OFF, NO, FALSE, N, IGNORE, NOTFOUND, the empty string, or a string ending in -NOTFOUND. Boolean constants are case-insensitive.

Existence tests:

cmake
if(DEFINED MY_VAR)                  # true if variable is set
if(DEFINED ENV{HOME})               # true if environment variable exists
if(EXISTS "/path/to/file")          # true if file or directory exists
if(IS_DIRECTORY "/path")            # true if path is a directory

Numeric comparisons: EQUAL, LESS, GREATER, LESS_EQUAL, GREATER_EQUAL

String comparisons: STREQUAL, STRLESS, STRGREATER, STRLESS_EQUAL, STRGREATER_EQUAL

Regex matching:

cmake
if("hello-world" MATCHES "^hello-(.+)$")
    message("Matched: ${CMAKE_MATCH_1}")    # world
endif()

Captured groups are stored in CMAKE_MATCH_0 (full match) through CMAKE_MATCH_9.

Version comparisons (format major.minor.patch.tweak):

cmake
if("${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.20")
    message("Modern CMake")
endif()

The if() automatic dereferencing trap

CMake's if() command was written before the ${VAR} syntax existed. For historical reasons, unquoted argument names are automatically looked up as variables:

cmake
set(FOO "YES")
if(FOO)              # dereferences FOO → "YES" → true
    message("true")
endif()

This means if(FOO) and if(${FOO}) do different things. The former checks the variable named FOO. The latter first expands FOO to YES, then checks a variable named YES (double dereferencing — almost never what you want).

Best practice: use if(VAR_NAME) (no ${}) to test truthiness, and if("${VAR}" STREQUAL "value") (quoted, with ${}) for comparisons. Start your scripts with cmake_minimum_required(VERSION 3.5) or later to enable the CMP0054 policy, which prevents quoted strings from being treated as variable names in if().

foreach / endforeach

Iterate over items:

cmake
foreach(fruit apple banana cherry)
    message("${fruit}")
endforeach()

Iterate over a list variable:

cmake
set(COLORS red green blue)
foreach(c IN LISTS COLORS)
    message("${c}")
endforeach()

The IN LISTS form takes a variable name (not ${COLORS}) and iterates over its elements.

Iterate with IN ITEMS (takes values, not variable names):

cmake
foreach(c IN ITEMS ${COLORS} purple)
    message("${c}")
endforeach()

Range loops:

cmake
foreach(i RANGE 5)              # 0, 1, 2, 3, 4, 5 (inclusive!)
    message("${i}")
endforeach()

foreach(i RANGE 2 8 2)          # 2, 4, 6, 8 (start, stop, step)
    message("${i}")
endforeach()

Note: RANGE is inclusive of both endpoints, unlike most languages.

Zip iteration (CMake 3.17+):

cmake
set(NAMES alice bob carol)
set(AGES 30 25 40)
foreach(name age IN ZIP_LISTS NAMES AGES)
    message("${name} is ${age}")
endforeach()

while / endwhile

cmake
set(N 5)
while(N GREATER 0)
    message("${N}")
    math(EXPR N "${N} - 1")
endwhile()

Conditions use the same syntax as if().

break(), continue(), return()

break() exits the innermost loop. continue() skips to the next iteration. return() exits the current function (or the entire script if called at the top level).

cmake
foreach(i RANGE 10)
    if(i EQUAL 3)
        continue()          # skip 3
    endif()
    if(i EQUAL 7)
        break()             # stop at 7
    endif()
    message("${i}")
endforeach()
# Output: 0, 1, 2, 4, 5, 6

10. Lists in depth

A CMake "list" is just a string containing semicolons. The string "apple;banana;cherry" is a list of three elements. This fundamental identity drives everything about how lists work.

cmake
set(FRUITS apple banana cherry)       # same as set(FRUITS "apple;banana;cherry")
list(LENGTH FRUITS count)
message("${count} fruits")            # 3 fruits

Core list operations

Reading:

cmake
list(LENGTH mylist count)             # number of elements
list(GET mylist 0 2 -1 result)        # elements at indices 0, 2, and last
list(FIND mylist "banana" idx)        # index of value, or -1
list(SUBLIST mylist 1 2 result)       # 2 elements starting at index 1
list(JOIN mylist ", " result)         # join with delimiter: "apple, banana, cherry"

Negative indices count from the end: -1 is the last element.

Modifying:

cmake
list(APPEND mylist "date")            # add to end
list(PREPEND mylist "avocado")        # add to front
list(INSERT mylist 2 "blueberry")     # insert at index 2
list(REMOVE_ITEM mylist "banana")     # remove by value
list(REMOVE_AT mylist 0 3)            # remove by index
list(REMOVE_DUPLICATES mylist)        # keep first occurrence of each
list(POP_FRONT mylist first second)   # remove and capture first elements
list(POP_BACK mylist last)            # remove and capture last element

Ordering:

cmake
list(REVERSE mylist)
list(SORT mylist)                     # alphabetical ascending
list(SORT mylist COMPARE NATURAL ORDER DESCENDING CASE INSENSITIVE)

Filtering:

cmake
set(FILES a.cpp b.h c.cpp d.txt)
list(FILTER FILES INCLUDE REGEX "\\.cpp$")    # keeps a.cpp, c.cpp
list(FILTER FILES EXCLUDE REGEX "\\.txt$")    # removes .txt files

Transform (apply an action to every element):

cmake
set(NAMES alice bob carol)
list(TRANSFORM NAMES TOUPPER)                  # ALICE;BOB;CAROL
list(TRANSFORM NAMES PREPEND "user_")          # user_ALICE;user_BOB;user_CAROL
list(TRANSFORM NAMES REPLACE "^user_" "u/")    # regex replace on each element

Transform supports selectors: AT <indices>, FOR <start> <stop> [<step>], REGEX <regex> to apply the action to only matching elements. Use OUTPUT_VARIABLE to store results in a different variable instead of modifying in place.

The list-string duality trap

Because lists are just strings with semicolons, you can accidentally create lists when you meant to build a string, and vice versa:

cmake
set(FLAGS "")
# WRONG: creates ";-Wall;-O2" (leading semicolon = empty first element)
list(APPEND FLAGS "-Wall" "-O2")

# ALSO WRONG for space-separated strings like compiler flags:
set(CFLAGS "-O2 -Wall")
list(APPEND CFLAGS "-Werror")
# CFLAGS is now "-O2 -Wall;-Werror" — the semicolon will break things

For space-separated strings like compiler flags, use string(APPEND):

cmake
string(APPEND CFLAGS " -Werror")

Use list() operations only for actual semicolon-separated lists.


11. String operations

The string() command is a Swiss army knife with many sub-commands.

Length and substring extraction

cmake
set(S "Hello, World!")
string(LENGTH "${S}" len)                  # 13
string(SUBSTRING "${S}" 7 5 sub)           # "World"
string(SUBSTRING "${S}" 7 -1 rest)         # "World!" (-1 = remainder)

Case conversion and stripping

cmake
string(TOUPPER "hello" result)             # "HELLO"
string(TOLOWER "HELLO" result)             # "hello"
string(STRIP "  spaces  " result)          # "spaces"

Search and replace

cmake
string(FIND "Hello World" "World" pos)     # pos = 6
string(FIND "abcabc" "bc" pos REVERSE)     # pos = 4 (last occurrence)

string(REPLACE ".cpp" ".o" result "main.cpp util.cpp")
# result = "main.o util.o"

Regex operations

cmake
# Match first occurrence
string(REGEX MATCH "[0-9]+" result "version 3.28.1 released")
# result = "3"

# Match all occurrences
string(REGEX MATCHALL "[0-9]+" result "version 3.28.1 released")
# result = "3;28;1" (a list)

# Replace with regex
string(REGEX REPLACE "([0-9]+)\\.([0-9]+)" "\\1_\\2" result "v3.28")
# result = "v3_28"
# Note: \\1, \\2 reference captured groups (double backslash in CMake strings)

Building strings incrementally

cmake
set(RESULT "")
string(APPEND RESULT "Hello")
string(APPEND RESULT ", " "World!")        # "Hello, World!"

# Join multiple strings with a delimiter
string(JOIN "/" result "usr" "local" "bin")  # "usr/local/bin"

Other useful operations

cmake
# Repeat a string
string(REPEAT "=-" 20 separator)           # "=-=-=-=-=-=-..."

# Timestamp
string(TIMESTAMP now "%Y-%m-%d %H:%M:%S")
message("Current time: ${now}")

# Make valid C identifier
string(MAKE_C_IDENTIFIER "my-header.h" id) # "my_header_h"

# Configure-style substitution
set(NAME "World")
string(CONFIGURE "Hello, @NAME@!" result @ONLY)
# result = "Hello, World!" — replaces @VAR@ references

# Hashing
string(SHA256 hash "some content")
message("Hash: ${hash}")

12. Math expressions

CMake provides integer math via math(EXPR). All values are 64-bit signed integers; floating point is not supported.

cmake
math(EXPR result "3 + 4 * 5")             # 23 (standard precedence)
math(EXPR result "(3 + 4) * 5")           # 35

set(A 100)
set(B 7)
math(EXPR quotient "${A} / ${B}")          # 14 (integer division, truncated)
math(EXPR remainder "${A} % ${B}")         # 2

Supported operators: +, -, *, /, %, | (bitwise OR), & (bitwise AND), ^ (bitwise XOR), ~ (bitwise NOT), << (left shift), >> (right shift), and parentheses for grouping.

Hexadecimal literals work with the 0x prefix, and you can control the output format:

cmake
math(EXPR result "0xFF + 1")                               # 256
math(EXPR result "255" OUTPUT_FORMAT HEXADECIMAL)           # "0xff"

13. File operations

The file() command provides portable file I/O.

Reading files

cmake
# Read entire file into a variable
file(READ "config.txt" content)

# Read file as a list of lines
file(STRINGS "config.txt" lines)
foreach(line IN LISTS lines)
    message("${line}")
endforeach()

# Read lines matching a pattern
file(STRINGS "config.txt" matches REGEX "^#")

# Get file size
file(SIZE "data.bin" size)
message("File size: ${size} bytes")

# Compute hash
file(SHA256 "data.bin" hash)
message("SHA-256: ${hash}")

file(STRINGS) reads a file line-by-line, strips binary content, and stores the result as a CMake list (one element per line). This is often more useful than file(READ) for text processing.

Writing files

cmake
file(WRITE "output.txt" "Line 1\n")          # creates/overwrites
file(APPEND "output.txt" "Line 2\n")         # appends

Multiple content arguments are concatenated:

cmake
file(WRITE "report.txt" "Name: " "${NAME}" "\nDate: " "${DATE}" "\n")

Finding files with globbing

cmake
file(GLOB txt_files "*.txt")                  # all .txt files in current dir
file(GLOB_RECURSE all_cmake "*.cmake")        # recursive search

# Relative paths
file(GLOB sources RELATIVE "${CMAKE_CURRENT_LIST_DIR}" "src/*.cpp")

Filesystem manipulation

cmake
file(MAKE_DIRECTORY "output/subdir")          # creates directory and parents
file(RENAME "old.txt" "new.txt")              # atomic rename/move
file(REMOVE "temp.txt" "temp2.txt")           # delete files (no error if missing)
file(REMOVE_RECURSE "build_dir")             # delete directory tree

# Copy files
file(COPY "src/config.ini" "src/data.json"
     DESTINATION "${CMAKE_CURRENT_LIST_DIR}/output")

# Get file modification time
file(TIMESTAMP "myfile.txt" mod_time "%Y-%m-%d %H:%M:%S")

Downloading and uploading

cmake
file(DOWNLOAD "https://example.com/data.json" "data.json"
     STATUS download_status
     TIMEOUT 30
     SHOW_PROGRESS)

list(GET download_status 0 status_code)
if(NOT status_code EQUAL 0)
    list(GET download_status 1 error_msg)
    message(FATAL_ERROR "Download failed: ${error_msg}")
endif()

The STATUS variable receives a list of two elements: a numeric return code (0 = success) and an error string.


14. Executing external programs

execute_process() runs external commands. It launches processes immediately (not at build time), making it perfect for scripts.

cmake
execute_process(
    COMMAND git log --oneline -5
    WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
    OUTPUT_VARIABLE git_log
    ERROR_VARIABLE git_err
    RESULT_VARIABLE git_result
    OUTPUT_STRIP_TRAILING_WHITESPACE
)

if(NOT git_result EQUAL 0)
    message(FATAL_ERROR "git failed: ${git_err}")
endif()
message("Recent commits:\n${git_log}")

Key options:

OptionPurpose
COMMANDThe program and its arguments
WORKING_DIRECTORYCWD for the process
OUTPUT_VARIABLECapture stdout
ERROR_VARIABLECapture stderr
RESULT_VARIABLEExit code (0 = success) or error string
OUTPUT_STRIP_TRAILING_WHITESPACERemove trailing whitespace from captured output
TIMEOUTKill process after N seconds
INPUT_FILE / OUTPUT_FILE / ERROR_FILERedirect stdin/stdout/stderr to files
COMMAND_ERROR_IS_FATAL ANYAutomatically fatal-error on non-zero exit

No shell is involved — arguments are passed directly to the program. Shell operators like >, |, and && are treated as literal arguments. Use INPUT_FILE/OUTPUT_FILE for redirection. For piping, use multiple COMMAND clauses, which create a pipeline:

cmake
execute_process(
    COMMAND grep "error" log.txt
    COMMAND wc -l
    OUTPUT_VARIABLE error_count
    OUTPUT_STRIP_TRAILING_WHITESPACE
)

Multiple COMMAND keywords in a single execute_process() call run concurrently as a pipeline (stdout of each piped to stdin of the next). For sequential execution, use separate execute_process() calls.

Portable commands with cmake -E: The CMake executable itself provides cross-platform utilities like copy, make_directory, remove, tar, touch, md5sum, and echo. Use ${CMAKE_COMMAND} to reference the current cmake binary:

cmake
execute_process(COMMAND "${CMAKE_COMMAND}" -E make_directory "output")
execute_process(COMMAND "${CMAKE_COMMAND}" -E copy "src.txt" "dest.txt")

15. Functions and macros

Functions

A function creates a new variable scope. Variables set inside a function are local — they do not affect the caller.

cmake
function(greet name)
    message("Hello, ${name}!")
    set(INTERNAL "secret")
endfunction()

greet("Alice")                    # Hello, Alice!
message("${INTERNAL}")            # empty — INTERNAL is not visible here

Function names are case-insensitive when called, but the convention is lowercase.

Returning values from a function requires PARENT_SCOPE:

cmake
function(add_numbers a b result_var)
    math(EXPR sum "${a} + ${b}")
    set(${result_var} "${sum}" PARENT_SCOPE)
endfunction()

add_numbers(3 4 ANSWER)
message("3 + 4 = ${ANSWER}")      # 3 + 4 = 7

The pattern is: the caller passes a variable name as an argument, and the function uses set(${result_var} value PARENT_SCOPE) to write the result into that variable in the caller's scope.

Critical detail: PARENT_SCOPE sets the variable only in the parent scope — not locally. If you need the value both locally and in the parent, set it twice:

cmake
function(compute out_var)
    set(result "42")
    set(${out_var} "${result}" PARENT_SCOPE)   # parent
    # result is still "42" locally, but ${out_var} itself is not set locally
endfunction()

With CMake 3.25+, you can use return(PROPAGATE var1 var2) as a cleaner alternative that sets variables in the parent scope and returns in one step.

Special argument variables

Inside a function (or macro), these variables are available:

VariableMeaning
ARGCNumber of arguments passed
ARGVAll arguments as a list
ARGNArguments beyond the named parameters (extra args)
ARGV0, ARGV1, ...Individual arguments by index
cmake
function(print_all first)
    message("First: ${first}")
    message("Total args: ${ARGC}")
    message("Extra args: ${ARGN}")
    foreach(arg IN LISTS ARGN)
        message("  Extra: ${arg}")
    endforeach()
endfunction()

print_all("A" "B" "C" "D")
# First: A
# Total args: 4
# Extra args: B;C;D
#   Extra: B
#   Extra: C
#   Extra: D

Macros

A macro looks like a function, but does not create a new scope. It executes as if its body were pasted in place at the call site:

cmake
macro(set_version major minor)
    set(VERSION "${major}.${minor}")
endmacro()

set_version(3 14)
message("${VERSION}")         # 3.14 — VERSION is set in the caller's scope

In a macro, ARGC, ARGV, ARGN, and ARGV0/ARGV1/... are not real CMake variables — they are string replacements (like C preprocessor macros). This means you cannot use them with commands that expect variable names:

cmake
macro(bad_example)
    # WRONG: ARGN is not a real variable in a macro
    foreach(arg IN LISTS ARGN)
        message("${arg}")
    endforeach()
endmacro()

# Workaround: copy to a real variable first
macro(good_example)
    set(_args "${ARGN}")
    foreach(arg IN LISTS _args)
        message("${arg}")
    endforeach()
endmacro()

Another macro gotcha: return() inside a macro does not return from the macro — it returns from the calling scope (the function or script that invoked the macro). This is almost never what you want.

Rule of thumb: prefer function() over macro(). Use macros only for very small utilities where you intentionally want to modify the caller's variables without the PARENT_SCOPE ceremony.


16. Portable path manipulation with cmake_path()

Added in CMake 3.20, cmake_path() performs pure syntactic path manipulation — no filesystem access. It always uses forward slashes and follows the same conventions as C++'s std::filesystem::path.

Important: cmake_path() takes a variable name, not a value. You pass the name of a variable containing a path.

cmake
set(mypath "/home/user/projects/app/src/main.cpp")

cmake_path(GET mypath FILENAME name)         # "main.cpp"
cmake_path(GET mypath EXTENSION ext)         # ".cpp"
cmake_path(GET mypath STEM stem)             # "main"
cmake_path(GET mypath PARENT_PATH parent)    # "/home/user/projects/app/src"

Building and modifying paths

cmake
set(base "/usr/local")
cmake_path(APPEND base "lib" "cmake")        # base = "/usr/local/lib/cmake"

# SET with automatic native path conversion (backslashes → forward slashes)
cmake_path(SET win_path "C:\\Users\\Alice\\Documents")
# win_path = "C:/Users/Alice/Documents"

# Normalize: collapse . and .. and redundant separators
set(messy "/home/user/./projects/../projects/app//src")
cmake_path(NORMAL_PATH messy)
# messy = "/home/user/projects/app/src"

Queries

cmake
cmake_path(IS_ABSOLUTE mypath result)        # TRUE or FALSE
cmake_path(IS_RELATIVE mypath result)
cmake_path(HAS_EXTENSION mypath result)
cmake_path(IS_PREFIX "/usr" "/usr/local/bin" NORMALIZE result)   # TRUE

Platform conversion

cmake
cmake_path(NATIVE_PATH mypath native)
# On Windows: native = "C:\Users\..."
# On Unix: native = "/home/user/..."

cmake_path(CONVERT "C:\\path\\to\\file" TO_CMAKE_PATH_LIST result)
# result = "C:/path/to/file"

For CMake versions before 3.20, use get_filename_component() or file(TO_CMAKE_PATH) as older alternatives for path operations.


17. Including other script files

The include() command sources another .cmake file in the current scope (no new scope is created, unlike function()):

cmake
# helpers.cmake
function(log msg)
    string(TIMESTAMP ts "%H:%M:%S")
    message(STATUS "[${ts}] ${msg}")
endfunction()
cmake
# main.cmake
include("${CMAKE_CURRENT_LIST_DIR}/helpers.cmake")
log("Script started")
log("Doing work...")

Variables set in the included file are visible in the including file. CMAKE_CURRENT_LIST_FILE and CMAKE_CURRENT_LIST_DIR update to reflect the included file during its processing, then restore when it finishes.

Useful options:

cmake
include(helpers OPTIONAL)                          # no error if not found
include(helpers OPTIONAL RESULT_VARIABLE found)    # found = full path or NOTFOUND

When no path is given (just a name like helpers), CMake searches CMAKE_MODULE_PATH and then its built-in module directory for helpers.cmake. You can also include built-in CMake modules:

cmake
include(CMakePrintHelpers)        # provides cmake_print_variables()
cmake_print_variables(CMAKE_VERSION CMAKE_COMMAND)

18. Practical examples

Example 1: a file-processing script

This script reads a template, substitutes placeholders, and writes the result:

cmake
# process_template.cmake
# Usage: cmake -DINPUT=template.txt -DOUTPUT=result.txt
#              -DPROJECT_NAME=MyApp -DVERSION=2.1.0 -P process_template.cmake
cmake_minimum_required(VERSION 3.20)

if(NOT DEFINED INPUT OR NOT DEFINED OUTPUT)
    message(FATAL_ERROR
        "Usage: cmake -DINPUT=<file> -DOUTPUT=<file> "
        "-DPROJECT_NAME=<name> -DVERSION=<ver> -P process_template.cmake")
endif()

# Read the template
file(READ "${INPUT}" content)

# Substitute placeholders
string(REPLACE "@PROJECT_NAME@" "${PROJECT_NAME}" content "${content}")
string(REPLACE "@VERSION@" "${VERSION}" content "${content}")
string(TIMESTAMP build_date "%Y-%m-%d")
string(REPLACE "@BUILD_DATE@" "${build_date}" content "${content}")

# Write result
file(WRITE "${OUTPUT}" "${content}")

file(SIZE "${OUTPUT}" out_size)
message(STATUS "Wrote ${OUTPUT} (${out_size} bytes)")

Example 2: a test-runner helper

cmake
# run_tests.cmake
# Usage: cmake -DBUILD_DIR=build -P run_tests.cmake
cmake_minimum_required(VERSION 3.20)

if(NOT DEFINED BUILD_DIR)
    set(BUILD_DIR "${CMAKE_CURRENT_LIST_DIR}/build")
endif()

# Build the project
message(STATUS "Building...")
execute_process(
    COMMAND "${CMAKE_COMMAND}" --build "${BUILD_DIR}" --config Release
    RESULT_VARIABLE rc
)
if(NOT rc EQUAL 0)
    message(FATAL_ERROR "Build failed (exit code ${rc})")
endif()

# Run tests
message(STATUS "Running tests...")
execute_process(
    COMMAND "${CMAKE_COMMAND}" -E chdir "${BUILD_DIR}"
            ctest --output-on-failure -C Release
    RESULT_VARIABLE rc
)
if(NOT rc EQUAL 0)
    message(FATAL_ERROR "Tests failed (exit code ${rc})")
endif()

message(STATUS "All tests passed.")

Example 3: portable file copy with logging

cmake
# deploy.cmake
# Usage: cmake -DSRC=dist -DDEST=/opt/myapp -P deploy.cmake
cmake_minimum_required(VERSION 3.20)

if(NOT DEFINED SRC OR NOT DEFINED DEST)
    message(FATAL_ERROR "Usage: cmake -DSRC=<dir> -DDEST=<dir> -P deploy.cmake")
endif()

# Discover files
file(GLOB_RECURSE all_files RELATIVE "${SRC}" "${SRC}/*")
list(LENGTH all_files total)
message(STATUS "Deploying ${total} files from ${SRC} to ${DEST}")

file(MAKE_DIRECTORY "${DEST}")

set(copied 0)
set(skipped 0)
foreach(f IN LISTS all_files)
    set(src_path "${SRC}/${f}")
    set(dst_path "${DEST}/${f}")

    # Get parent directory and ensure it exists
    cmake_path(GET dst_path PARENT_PATH dst_dir)
    file(MAKE_DIRECTORY "${dst_dir}")

    # Copy only if source is newer
    if(EXISTS "${dst_path}")
        if("${src_path}" IS_NEWER_THAN "${dst_path}")
            file(COPY_FILE "${src_path}" "${dst_path}")
            math(EXPR copied "${copied} + 1")
        else()
            math(EXPR skipped "${skipped} + 1")
        endif()
    else()
        file(COPY_FILE "${src_path}" "${dst_path}")
        math(EXPR copied "${copied} + 1")
    endif()
endforeach()

message(STATUS "Done: ${copied} copied, ${skipped} up-to-date.")

19. Common pitfalls summarized

These are the mistakes that cause the most debugging pain. Each one is easy to avoid once you know about it.

Always quote variable expansions. Write "${VAR}", not ${VAR}, in almost all contexts. Without quotes, empty variables vanish (breaking if() argument counts) and semicolons silently split values into multiple arguments. The only time to leave quotes off is when you deliberately want list expansion, such as foreach(item ${MY_LIST}).

Don't double-dereference in if(). Write if(MY_VAR) to test whether a variable is set and truthy. Writing if(${MY_VAR}) first expands MY_VAR and then tries to dereference the result as another variable name — a confusing double lookup. For string comparisons, use if("${MY_VAR}" STREQUAL "value").

Lists are strings, strings are lists. There is no separate list type. A semicolon in any string makes it a multi-element list. This means list(APPEND) on a space-separated string like compiler flags will embed a semicolon that breaks downstream tools. Use string(APPEND) for space-separated strings; reserve list() for actual semicolon-delimited lists.

set() with PARENT_SCOPE does not set locally. After set(X "val" PARENT_SCOPE), the variable X is unchanged in the current scope. If you need it both locally and in the parent, call set() twice — once without and once with PARENT_SCOPE. In CMake 3.25+, use return(PROPAGATE X) instead.

Macros leak everything. A macro runs in the caller's scope. Variables you set inside a macro persist after it returns. Macro arguments (ARGC, ARGV, ARGN) are string substitutions, not real variables, so foreach(x IN LISTS ARGN) inside a macro looks up ARGN as a variable name in the caller's scope — not the macro's arguments. Prefer functions unless you have a specific reason to modify the caller's scope.

Nested list flattening. set(X a "b;c") produces a;b;c — a flat list of three elements, not a nested structure. CMake lists do not support elements containing semicolons (without workarounds). If you need structured data, consider using variable-name conventions like MAP_key instead of trying to nest lists.

Start scripts with cmake_minimum_required(). Even in -P mode, this sets CMake policies that control language behavior. Without it, you may get legacy behaviors like if() dereferencing quoted strings (CMP0054) or if() not recognizing numeric constants properly (CMP0012). Use cmake_minimum_required(VERSION 3.16) or later as a sensible baseline.


Conclusion

CMake's scripting mode is a capable cross-platform alternative to Bash or Python for build-adjacent tasks. You invoke it with cmake -P script.cmake, pass data via -D flags, and get access to the full CMake language minus build-target commands. The language has real depth — string() alone has over a dozen sub-commands, file() can read, write, glob, hash, download, and copy, and execute_process() can run any external program with full I/O capture.

The three concepts that unlock productive CMake scripting are: everything is a string (including lists, which are semicolon-delimited strings), always quote "${VAR}" (preventing the two most common bug categories), and functions create scope, macros don't (which determines where you use each). Start with cmake_minimum_required(VERSION 3.20) at the top of every script, use cmake_path() for portable path handling, and lean on ${CMAKE_COMMAND} -E for cross-platform shell operations. With these foundations, you can write deployment scripts, test harnesses, file processors, and automation tools that run identically on Windows, macOS, and Linux — anywhere CMake is installed.

Content is user-generated and unverified.
    CMake as a Scripting Language: Complete Guide to cmake -P | Claude