Table of Contents

  1. Configure-time vs Build-time
  2. execute_process
  3. try_compile
  4. try_run
  5. add_custom_command (OUTPUT)
  6. add_custom_command (TARGET)
  7. add_custom_target
  8. file() Operations
  9. CMake Script Mode
  10. Practical Patterns
  11. Conclusion & Next Steps
Back to CMake Mastery Series

Part 14: Configure-time and Build-time Operations

June 4, 2026 Wasil Zafar 40 min read

Understand what runs when in CMake — from probing the system at configure-time with execute_process and try_compile to generating files at build-time with custom commands, custom targets, and script mode.

Configure-time vs Build-time

CMake operates in two distinct phases, and understanding which commands run when is fundamental to writing correct build logic. Commands in your CMakeLists.txt execute at configure-time (when you run cmake -S . -B build), while the build system produced by CMake executes its rules at build-time (when you run cmake --build build).

CMake Phase Timeline
        flowchart LR
            A["cmake -S . -B build"] --> B["Configure Phase"]
            B --> C["Generate Phase"]
            C --> D["Build System Files
(Makefiles/Ninja)"] D --> E["cmake --build build"] E --> F["Build Phase"] F --> G["Compiled Binaries"] style B fill:#3B9797,color:#fff style C fill:#3B9797,color:#fff style F fill:#132440,color:#fff

What's Available at Each Phase

At configure-time, you have full access to the CMake scripting language — variables, control flow, execute_process(), try_compile(), and file() operations. However, you don't have compiled binaries yet, and generator expressions haven't been evaluated.

At build-time, the native build system takes over. CMake's variables no longer exist — only the rules written into Makefiles or Ninja files execute. Custom commands and custom targets fire during this phase.

Key Insight: If you need information that only exists at build-time (like the final output directory of a target), you must use generator expressions or add_custom_command — not CMake variables, which are frozen once configuration ends.
# Configure-time: runs NOW during cmake invocation
message(STATUS "Configuring project: ${PROJECT_NAME}")
execute_process(COMMAND git rev-parse HEAD OUTPUT_VARIABLE GIT_HASH)

# Build-time: runs LATER during make/ninja invocation
add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/timestamp.txt
    COMMAND ${CMAKE_COMMAND} -E echo "Built at: $<$<BOOL:1>:now>"
    COMMENT "Generating timestamp"
)

execute_process — Running Commands at Configure-time

The execute_process() command runs one or more external processes during configuration. It's your primary tool for probing the system, extracting version strings, checking tool availability, and running scripts that produce information needed by the build.

cmake_minimum_required(VERSION 3.20)
project(ConfigProbe)

# Simple command execution
execute_process(
    COMMAND git rev-parse --short HEAD
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    OUTPUT_VARIABLE GIT_SHORT_HASH
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET
)
message(STATUS "Git hash: ${GIT_SHORT_HASH}")

Capturing Output

The execute_process() command provides several output-capture keywords:

execute_process(
    COMMAND python3 --version
    OUTPUT_VARIABLE PYTHON_VERSION_STRING
    ERROR_VARIABLE PYTHON_ERROR
    RESULT_VARIABLE PYTHON_RESULT
    OUTPUT_STRIP_TRAILING_WHITESPACE
)

if(NOT PYTHON_RESULT EQUAL 0)
    message(WARNING "Python3 not found: ${PYTHON_ERROR}")
else()
    message(STATUS "Found: ${PYTHON_VERSION_STRING}")
endif()

You can also pipe commands together just like a shell pipeline:

# Pipe: equivalent to "cat version.txt | tr -d '\n'"
execute_process(
    COMMAND cat ${CMAKE_SOURCE_DIR}/version.txt
    COMMAND tr -d "\n"
    OUTPUT_VARIABLE FILE_VERSION
    OUTPUT_STRIP_TRAILING_WHITESPACE
)

Error Handling

Robust error handling is critical for cross-platform builds. Use RESULT_VARIABLE and optionally COMMAND_ERROR_IS_FATAL (CMake 3.19+):

# CMake 3.19+: automatic fatal error on failure
execute_process(
    COMMAND pkg-config --modversion libssl
    OUTPUT_VARIABLE SSL_VERSION
    OUTPUT_STRIP_TRAILING_WHITESPACE
    COMMAND_ERROR_IS_FATAL ANY
)

# Pre-3.19: manual check
execute_process(
    COMMAND which clang-format
    RESULT_VARIABLE CLANG_FORMAT_RESULT
    OUTPUT_VARIABLE CLANG_FORMAT_PATH
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET
)
if(CLANG_FORMAT_RESULT EQUAL 0)
    message(STATUS "clang-format found: ${CLANG_FORMAT_PATH}")
endif()
Experiment System Probing
Detecting Compiler Capabilities at Configure-time

Create a CMakeLists.txt that uses execute_process() to detect whether the system has ccache installed. If found, configure the project to use it as a compiler launcher. If not found, print a status message and continue normally.

execute_process RESULT_VARIABLE CMAKE_CXX_COMPILER_LAUNCHER

try_compile — Testing if Code Compiles

The try_compile() command attempts to compile a source file (or a whole project) at configure-time, storing whether the compilation succeeded. This is the CMake equivalent of autoconf's AC_TRY_COMPILE.

cmake_minimum_required(VERSION 3.20)
project(FeatureProbe CXX)

# Try compiling a test file
try_compile(HAS_FILESYSTEM
    ${CMAKE_BINARY_DIR}/try_compile
    ${CMAKE_SOURCE_DIR}/cmake/checks/has_filesystem.cpp
    CXX_STANDARD 17
)

if(HAS_FILESYSTEM)
    message(STATUS "std::filesystem is available")
    target_compile_definitions(myapp PRIVATE HAS_STD_FILESYSTEM)
else()
    message(STATUS "std::filesystem not available, using fallback")
endif()

check_cxx_source_compiles

For quick inline checks without creating separate test files, CMake provides the CheckCXXSourceCompiles module:

include(CheckCXXSourceCompiles)

# Check for C++20 concepts support
check_cxx_source_compiles("
    #include <concepts>
    template<typename T>
    concept Numeric = std::integral<T> || std::floating_point<T>;
    int main() { return 0; }
" HAS_CONCEPTS)

# Check for specific POSIX function
include(CheckSymbolExists)
check_symbol_exists(mmap "sys/mman.h" HAS_MMAP)

# Check for a compiler flag
include(CheckCXXCompilerFlag)
check_cxx_compiler_flag("-fsanitize=address" HAS_ASAN)
if(HAS_ASAN)
    target_compile_options(myapp PRIVATE -fsanitize=address)
    target_link_options(myapp PRIVATE -fsanitize=address)
endif()

try_run — Running Compiled Code at Configure-time

The try_run() command compiles and runs a test program at configure-time. This is useful for detecting runtime characteristics like endianness, type sizes, or library behavior:

try_run(RUN_RESULT COMPILE_RESULT
    ${CMAKE_BINARY_DIR}/try_run
    ${CMAKE_SOURCE_DIR}/cmake/checks/check_endian.cpp
    RUN_OUTPUT_VARIABLE ENDIAN_OUTPUT
)

if(COMPILE_RESULT AND RUN_RESULT EQUAL 0)
    message(STATUS "Endianness: ${ENDIAN_OUTPUT}")
endif()

Cross-Compilation Caveats

When cross-compiling, try_run() cannot execute the compiled binary on the host machine (it's built for a different architecture). CMake handles this with a try-run results file:

Cross-Compilation Warning: try_run() will fail during cross-compilation unless you provide pre-cached results via -C TryRunResults.cmake. For portable projects, prefer try_compile() over try_run() wherever possible.
# For cross-compilation, cache the results:
# In TryRunResults.cmake:
set(ENDIAN_RUN_RESULT 0 CACHE STRING "")
set(ENDIAN_RUN_OUTPUT "little" CACHE STRING "")

# Then configure with:
# cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=arm.cmake -C TryRunResults.cmake

add_custom_command (OUTPUT Form)

The add_custom_command(OUTPUT ...) form generates files at build-time. Unlike execute_process(), these commands don't run during configuration — they produce rules in the build system that fire only when their outputs are needed:

cmake_minimum_required(VERSION 3.20)
project(CodeGen CXX)

# Generate a header from a template at BUILD time
add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/generated/config_report.h
    COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/generated
    COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/gen_config.py
        --output ${CMAKE_BINARY_DIR}/generated/config_report.h
    DEPENDS ${CMAKE_SOURCE_DIR}/scripts/gen_config.py
    COMMENT "Generating config_report.h"
    VERBATIM
)

add_executable(myapp src/main.cpp ${CMAKE_BINARY_DIR}/generated/config_report.h)
target_include_directories(myapp PRIVATE ${CMAKE_BINARY_DIR}/generated)

The DEPENDS Keyword

The DEPENDS keyword is crucial — it tells the build system to re-run the custom command when any dependency changes:

# DEPENDS on a file: rebuild when input changes
add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/messages.pb.h ${CMAKE_BINARY_DIR}/messages.pb.cc
    COMMAND protoc --cpp_out=${CMAKE_BINARY_DIR}
        -I${CMAKE_SOURCE_DIR}/proto
        ${CMAKE_SOURCE_DIR}/proto/messages.proto
    DEPENDS ${CMAKE_SOURCE_DIR}/proto/messages.proto
    COMMENT "Running protoc on messages.proto"
    VERBATIM
)

# DEPENDS on a target: rebuild when that target is rebuilt
add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/checksum.txt
    COMMAND ${CMAKE_COMMAND} -E sha256sum $<TARGET_FILE:myapp>
        > ${CMAKE_BINARY_DIR}/checksum.txt
    DEPENDS myapp
    COMMENT "Computing checksum of myapp"
    VERBATIM
)
Custom Command Dependency Graph
        flowchart TD
            A["messages.proto"] -->|DEPENDS| B["add_custom_command
protoc"] B --> C["messages.pb.h"] B --> D["messages.pb.cc"] C --> E["add_executable
myapp"] D --> E F["main.cpp"] --> E E --> G["myapp binary"] G -->|DEPENDS| H["add_custom_command
sha256sum"] H --> I["checksum.txt"] style B fill:#3B9797,color:#fff style H fill:#3B9797,color:#fff style E fill:#132440,color:#fff

add_custom_command (TARGET Form)

The TARGET form attaches a command to an existing target, executing it at a specific point in the target's build process:

PRE_BUILD

# Note: PRE_BUILD only works as expected with Visual Studio generators.
# On Makefile/Ninja generators, it behaves like PRE_LINK.
add_custom_command(
    TARGET myapp PRE_BUILD
    COMMAND ${CMAKE_COMMAND} -E echo "Starting build of myapp..."
    VERBATIM
)
# Runs after compilation but before linking
add_custom_command(
    TARGET myapp PRE_LINK
    COMMAND ${CMAKE_COMMAND} -E echo "All objects compiled, about to link..."
    VERBATIM
)

POST_BUILD

# Runs after the target is built — most commonly used
add_custom_command(
    TARGET myapp POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:myapp> ${CMAKE_SOURCE_DIR}/bin/
    COMMENT "Copying myapp to bin/"
    VERBATIM
)

# Copy shared library dependencies on Windows
add_custom_command(
    TARGET myapp POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        $<TARGET_RUNTIME_DLLS:myapp> $<TARGET_FILE_DIR:myapp>
    COMMAND_EXPAND_LISTS
    COMMENT "Copying runtime DLLs"
    VERBATIM
)
Official Reference: The distinction between OUTPUT and TARGET forms is documented in add_custom_command. The TARGET form never creates a dependency edge — it's a side-effect attached to an existing target.

add_custom_target — Always-Out-of-Date Targets

The add_custom_target() command creates a target that is always considered out of date — its commands run every time you invoke it. This is perfect for utility tasks like formatting, linting, or documentation generation:

# A "format" target that runs clang-format on all sources
file(GLOB_RECURSE ALL_SOURCES src/*.cpp src/*.h include/*.h)

add_custom_target(format
    COMMAND clang-format -i ${ALL_SOURCES}
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    COMMENT "Running clang-format on all sources"
    VERBATIM
)

# A "docs" target that runs Doxygen
add_custom_target(docs
    COMMAND doxygen ${CMAKE_SOURCE_DIR}/Doxyfile
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    COMMENT "Generating API documentation"
    VERBATIM
)

Coordination Targets

Custom targets can also serve as coordination points using DEPENDS and add_dependencies():

# Generate multiple protocol buffer files
add_custom_command(OUTPUT msg_a.pb.cc COMMAND protoc ... DEPENDS msg_a.proto)
add_custom_command(OUTPUT msg_b.pb.cc COMMAND protoc ... DEPENDS msg_b.proto)

# Coordination target: ensures all proto files are generated
add_custom_target(generate_protos
    DEPENDS msg_a.pb.cc msg_b.pb.cc
    COMMENT "All protocol buffers generated"
)

# Main target depends on the coordination target
add_executable(server src/main.cpp msg_a.pb.cc msg_b.pb.cc)
add_dependencies(server generate_protos)
Experiment Build Automation
POST_BUILD Asset Packaging

Create a project where a POST_BUILD custom command on an executable target packages the binary along with a config.json and a README.txt into a dist/ directory. Use cmake -E commands for portability.

POST_BUILD cmake -E packaging

file() Operations

The file() command is a Swiss-army knife for filesystem operations at configure-time. Some sub-commands also work at generate-time.

GLOB and GLOB_RECURSE

# Collect source files (use with caution — see warning below)
file(GLOB SOURCES src/*.cpp)
file(GLOB_RECURSE ALL_HEADERS CONFIGURE_DEPENDS include/*.h)

# CONFIGURE_DEPENDS (CMake 3.12+): re-run configure if files change
# Warning: still not recommended for source lists by CMake developers
Warning: The CMake documentation explicitly discourages using file(GLOB) to collect source files. If you add or remove a source file, CMake won't know to re-configure unless you use CONFIGURE_DEPENDS (which has performance implications). Prefer explicit source lists.

DOWNLOAD, COPY, and READ/WRITE

# Download a file at configure-time
file(DOWNLOAD
    "https://example.com/data/test-vectors.json"
    ${CMAKE_BINARY_DIR}/test-vectors.json
    STATUS DOWNLOAD_STATUS
    TIMEOUT 30
    EXPECTED_HASH SHA256=abc123...
)
list(GET DOWNLOAD_STATUS 0 STATUS_CODE)
if(NOT STATUS_CODE EQUAL 0)
    message(FATAL_ERROR "Download failed: ${DOWNLOAD_STATUS}")
endif()

# Read a file into a variable
file(READ ${CMAKE_SOURCE_DIR}/VERSION.txt PROJECT_VERSION_STRING)
string(STRIP "${PROJECT_VERSION_STRING}" PROJECT_VERSION_STRING)

# Write content to a file
file(WRITE ${CMAKE_BINARY_DIR}/build_info.txt
    "Built on: ${CMAKE_SYSTEM_NAME}\n"
    "Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}\n"
)

# Copy files
file(COPY ${CMAKE_SOURCE_DIR}/assets/ DESTINATION ${CMAKE_BINARY_DIR}/assets)

file(GENERATE) — Generate-time File Creation

Unlike other file() sub-commands, file(GENERATE) runs at generate-time (after configuration, before build). This means it can use generator expressions:

# Generate a file with generator expressions
file(GENERATE
    OUTPUT ${CMAKE_BINARY_DIR}/$<CONFIG>/paths.txt
    CONTENT "Binary: $<TARGET_FILE:myapp>\nConfig: $<CONFIG>\n"
)

# Generate from an input template
file(GENERATE
    OUTPUT ${CMAKE_BINARY_DIR}/launch_$<CONFIG>.sh
    INPUT ${CMAKE_SOURCE_DIR}/cmake/launch.sh.in
    FILE_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE
)

CMake Script Mode

CMake can execute .cmake files as standalone scripts using the -P flag. Script mode has no concept of targets, generators, or build directories — it's pure CMake scripting:

# Run a CMake script directly
cmake -P scripts/cleanup.cmake

# Pass variables to the script
cmake -DOUTPUT_DIR=/tmp/artifacts -P scripts/package.cmake

Utility Scripts

# scripts/cleanup.cmake — standalone utility script
cmake_minimum_required(VERSION 3.20)

# Remove build artifacts
set(DIRS_TO_CLEAN build dist .cache)
foreach(dir IN LISTS DIRS_TO_CLEAN)
    if(EXISTS ${CMAKE_CURRENT_LIST_DIR}/../${dir})
        message(STATUS "Removing: ${dir}/")
        file(REMOVE_RECURSE ${CMAKE_CURRENT_LIST_DIR}/../${dir})
    endif()
endforeach()

message(STATUS "Cleanup complete")
# scripts/version_bump.cmake — bump patch version
cmake_minimum_required(VERSION 3.20)

file(READ "${CMAKE_CURRENT_LIST_DIR}/../VERSION.txt" VERSION)
string(STRIP "${VERSION}" VERSION)
string(REPLACE "." ";" VERSION_LIST "${VERSION}")
list(GET VERSION_LIST 0 MAJOR)
list(GET VERSION_LIST 1 MINOR)
list(GET VERSION_LIST 2 PATCH)

math(EXPR PATCH "${PATCH} + 1")
set(NEW_VERSION "${MAJOR}.${MINOR}.${PATCH}")

file(WRITE "${CMAKE_CURRENT_LIST_DIR}/../VERSION.txt" "${NEW_VERSION}\n")
message(STATUS "Version bumped: ${VERSION} -> ${NEW_VERSION}")

Script mode is also commonly used inside add_custom_command for portable build-time operations:

# Use cmake -P inside a custom command for portable file operations
add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/manifest.json
    COMMAND ${CMAKE_COMMAND} -P ${CMAKE_SOURCE_DIR}/cmake/generate_manifest.cmake
    DEPENDS ${CMAKE_SOURCE_DIR}/cmake/generate_manifest.cmake
    COMMENT "Generating build manifest"
    VERBATIM
)

Practical Patterns

Downloading Resources at Configure-time

# Download test data only if not already cached
set(TEST_DATA_URL "https://example.com/testdata/v2.tar.gz")
set(TEST_DATA_FILE ${CMAKE_BINARY_DIR}/testdata.tar.gz)
set(TEST_DATA_DIR ${CMAKE_BINARY_DIR}/testdata)

if(NOT EXISTS ${TEST_DATA_DIR})
    message(STATUS "Downloading test data...")
    file(DOWNLOAD ${TEST_DATA_URL} ${TEST_DATA_FILE}
        STATUS DL_STATUS
        SHOW_PROGRESS
    )
    list(GET DL_STATUS 0 DL_CODE)
    if(NOT DL_CODE EQUAL 0)
        message(FATAL_ERROR "Failed to download test data")
    endif()
    
    # Extract
    file(ARCHIVE_EXTRACT INPUT ${TEST_DATA_FILE}
        DESTINATION ${TEST_DATA_DIR}
    )
    file(REMOVE ${TEST_DATA_FILE})
endif()

Stamp Files for Caching Expensive Operations

When a custom command is expensive but its inputs rarely change, use a stamp file pattern to avoid unnecessary re-execution:

# Stamp file pattern: only re-run if inputs are newer than stamp
set(CODEGEN_STAMP ${CMAKE_BINARY_DIR}/codegen.stamp)

add_custom_command(
    OUTPUT ${CODEGEN_STAMP}
    COMMAND python3 ${CMAKE_SOURCE_DIR}/tools/codegen.py
        --input ${CMAKE_SOURCE_DIR}/schema/
        --output ${CMAKE_BINARY_DIR}/generated/
    COMMAND ${CMAKE_COMMAND} -E touch ${CODEGEN_STAMP}
    DEPENDS
        ${CMAKE_SOURCE_DIR}/tools/codegen.py
        ${CMAKE_SOURCE_DIR}/schema/api.yaml
        ${CMAKE_SOURCE_DIR}/schema/types.yaml
    COMMENT "Running code generator"
    VERBATIM
)

add_custom_target(codegen DEPENDS ${CODEGEN_STAMP})
add_dependencies(myapp codegen)
Experiment Script Mode
Build-time Resource Validator

Write a CMake script (validate_assets.cmake) that checks all .json files in an assets/ directory for valid JSON syntax using cmake -E and file(READ). Integrate it as a POST_BUILD custom command on your main executable, so asset integrity is verified after every successful build.

cmake -P POST_BUILD file(READ) validation

Conclusion & Next Steps

Understanding the boundary between configure-time and build-time is one of the most important concepts in CMake mastery. Here's a summary of when to use each tool:

Task Phase Command
Probe system environmentConfigureexecute_process()
Test if code compilesConfiguretry_compile()
Run code to detect featuresConfiguretry_run()
Generate source filesBuildadd_custom_command(OUTPUT)
Post-process binariesBuildadd_custom_command(TARGET)
Utility tasks (format, lint)Buildadd_custom_target()
Filesystem manipulationConfigurefile()
Generate-expression-aware filesGeneratefile(GENERATE)
Portable build scriptsBuildcmake -P
Official Reference: For complete documentation on all commands discussed in this article, see the CMake Reference Documentation, particularly the cmake-commands(7) manual page.