Ukraine Humanitarian Pooled Fund Analysis

Allocation patterns, partner concentration, grant scale and the health sector

Author

Jesus Baena

Published

April 24, 2026

1 Introduction

This report analyses the Ukraine Humanitarian Pooled Fund (UHF) from its first allocation in 2019 through 2025, and situates it within the broader CBPF system using comparative data from 28 country-based pooled funds over 2014–2025 (the mature operational period of the modality).

Rather than following the linear logic of the project cycle (number of beneficiaries, project duration, funding executed, etc.), this report steps back to examine overall funding flows and grant characteristics, with particular attention to the health sector.

The analysis is structured around three key questions:

What is the profile of the organizations receiving UHF funding? How does it compare with the rest of the CBPF system? What are the implications for the health sector?

1.1 Key Findings

  • Heavy reliance on the reserve allocation: Since Ukraine became an active CBPF in 2019, Reserve allocations have accounted for 51.9 % of its budget (2019–2025) — well above the non-Ukraine CBPF average of 40.2 % — placing Ukraine among the top tier of funds (7th of 28) in reserve dependence, alongside Sudan, oPt and DRC. The balance is correcting in 2025.
  • Larger grants, not fewer partners: Ukraine grants are 2.6× larger at the median than in the rest of the CBPF system ($1.13M vs $440K). This concentration of associates — rather than a narrower partner base — is a defining feature of the UHF portfolio.
  • Same shape × bigger envelope = bigger grants: In general numbers, UHF is not more concentrated than peer CBPFs. Across 2022–2024 Ukraine sits mid-pack among broad-partnership CBPFs on HHI (0.029), top-5 share (28%) and top-10 share (46%). What sets UHF apart is envelope size: $541M spread across 94 partners produces a mean partner take of $5.75M over three years — roughly 2× Yemen or Ethiopia and 4× Somalia.
  • A stable core-20 captures 55% of the envelope: Twenty organisations appeared in every UHF allocation round 2022–2024 and absorbed $299M of the $541M envelope. Seven of them (ACTED, UNHCR, DRC, PIN, Caritas Ukraine, NRC, Proliska) hold mean grants of $3.4M–$4.8M — at or near the $5M cap — with individual exceptional-cap grants up to $9.4M.
  • Ceiling-bound distribution: UHF’s P90 grant size is exactly $5.00M (the standard cap) — unique among CBPFs. Peer funds show a long tail; UHF shows a flat top.
  • Visible capacity-volume mismatches: Where 2021 revenue is public, some organizations has managed UHF volumes 4.3× its pre-war annual revenue ($9.4M against €1.9M). Seven of the eight core Ukrainian NNGOs have no publicly available 2021 revenue — an independent-assessment gap rather than a finding.
  • Structural UN exit: UN-Agency funding collapsed from $66M (2022) to $41M (2023) to $5M (2024); the 2024 Annual Report makes this explicit (“UN would no longer be funded unless absolutely necessary”). Redistributed volume flowed primarily to DRC, ICF Caritas Ukraine and a broadened NNGO tier. Probably also driven by a sufficient multilateral funding portfolio.
  • UHF-in-context: AR-reported, Ukraine = 20% of global CBPF allocations (2024) and >10% of the 2024 HNRP — the largest CBPF for the third consecutive year.
  • Data Sources: Official UHF allocation and project data (CBPF Feb 2026 extract); UHF Annual Reports 2022, 2023, 2024.

2 Data Overview

Data is retrieved from the official repository data explorer, it was downloaded on February 2026 and it is formed by raw .csv files. The raw data spans from 2010 onward, but analysis focuses on 2014-2025 to ensure comparability (2010-2013 was the pilot phase with limited geographic coverage). Data was cut at the end of 2025 to use static data for this analysis. As such, the recently announced top-up allocation for Ukraine from the US State Department in February 2026 is not included in this report. A posteriour update could be considered.

In total the data comprises:

  • 28 country-based pooled funds (one per PooledFundName, including two dedicated Regional Humanitarian Pooled Funds — Chad RhPF and Mozambique RhPF — and “Syria Cross-border” alongside Syria)

  • 16,304 approved projects across all years; 15,657 within the 2014–2025 analysis window

  • 891 distinct allocation rounds (AllocationType), e.g. “2022 3rd Reserve Allocation”, “2024 1st Standard Allocation”

Show code
# Load all data files - Latest download from February 19, 2026
# Using complete global dataset (ALL versions) for all 28 CBPF countries
project_summary <- read_csv("Raw-data/ProjectSummary_ALL_20260219_100323_UTC.csv", 
                            show_col_types = FALSE)
project_summary_location <- read_csv("Raw-data/ProjectSummaryWithLocation_ALL_20260219_100323_UTC.csv", 
                                     show_col_types = FALSE)
pipeline_summary <- read_csv("Raw-data/PipelineProjectSummary_ALL_20260219_125012_UTC.csv", 
                             show_col_types = FALSE)
pipeline_cluster <- read_csv("Raw-data/PipelineProjectCluster_ALL_20260219_100323_UTC.csv", 
                             show_col_types = FALSE)
clusters <- read_csv("Raw-data/Cluster_ALL_20260219_100323_UTC.csv", 
                     show_col_types = FALSE)
contributions <- read_csv("Raw-data/Contribution_ALL_20260219_100323_UTC.csv", 
                          show_col_types = FALSE)

2.1 Data Preparation

The raw data is well-structured and consistent with expected values. Minor transformations were applied to standardise date formats across datasets and to filter out 2026 entries, keeping the analysis within the 2014–2025 window. Helper functions safe_date_format and safe_year_format handle edge cases in date parsing robustly.

Date conversions

Dataset Date columns converted
ProjectSummary ProjectStartDate, ProjectEndDatemdy_hms()
ProjectSummaryWithLocation ActualStartDate, ActualEndDatemdy_hms()
PipelineProjectSummary ActualStartDate, ActualEndDatemdy_hms()
pipeline_cluster, clusters Use numeric AllocationYear — no conversion needed
contributions PledgeDate parsed on demand during analysis

Temporal cutoff — analysis endpoint: December 31, 2025

  • AllocationYear != 2026 applied to: project_summary, project_summary_location, pipeline_summary, pipeline_cluster
  • FiscalYear != 2026 applied to: contributions

Helper functions

  • safe_date_format() — returns the min/max of a date vector, filtered to the 2000–2025 range; returns "N/A" if no valid dates remain
  • safe_year_format() — same logic for numeric year vectors; returns the result as a character string
Show code
# ============================================================
# PART 1: Date and Type Conversions
# ============================================================

# Parse date columns for project datasets
project_summary <- project_summary %>%
  mutate(
    ProjectStartDate = mdy_hms(ProjectStartDate),
    ProjectEndDate = mdy_hms(ProjectEndDate)
  )

# Note: project_summary_location and pipeline_summary use ActualStartDate
project_summary_location <- project_summary_location %>%
  mutate(
    ActualStartDate = mdy_hms(ActualStartDate),
    ActualEndDate = mdy_hms(ActualEndDate)
  )

pipeline_summary <- pipeline_summary %>%
  mutate(
    ActualStartDate = mdy_hms(ActualStartDate),
    ActualEndDate = mdy_hms(ActualEndDate)
  )

# Note: pipeline_cluster and clusters use AllocationYear (numeric)
# Note: contributions uses PledgeDate (will be parsed when needed)

# ============================================================
# PART 2: Filter Out 2026 Data
# ============================================================
# Remove all data from 2026 to maintain analysis cutoff at end of 2025

project_summary <- project_summary %>%
  filter(AllocationYear != 2026)

project_summary_location <- project_summary_location %>%
  filter(AllocationYear != 2026)

pipeline_summary <- pipeline_summary %>%
  filter(AllocationYear != 2026)

pipeline_cluster <- pipeline_cluster %>%
  filter(AllocationYear != 2026)

contributions <- contributions %>%
  filter(FiscalYear != 2026)

# ============================================================
# PART 3: Helper Functions
# ============================================================

# Safely format date ranges, filtering invalid dates
safe_date_format <- function(date_vec, func = min) {
  valid_dates <- date_vec[!is.na(date_vec) & 
                          year(date_vec) >= 2000 & 
                          year(date_vec) <= 2025]
  
  if (length(valid_dates) == 0) return("N/A")
  
  result <- func(valid_dates)
  return(format(result, "%B %d, %Y"))
}

# Format year ranges for datasets that use AllocationYear
safe_year_format <- function(year_vec, func = min) {
  valid_years <- year_vec[!is.na(year_vec) & year_vec >= 2000 & year_vec <= 2025]
  
  if (length(valid_years) == 0) return("N/A")
  
  return(as.character(func(valid_years)))
}
Show code
data_summary <- tibble(
  Dataset = c("Project Summary", "Project Summary (Location)", 
              "Pipeline Summary", "Pipeline Cluster", "Clusters", "Contributions"),
  Rows = c(nrow(project_summary), nrow(project_summary_location),
           nrow(pipeline_summary), nrow(pipeline_cluster), nrow(clusters), 
           nrow(contributions)),
  Columns = c(ncol(project_summary), ncol(project_summary_location),
              ncol(pipeline_summary), ncol(pipeline_cluster), ncol(clusters), 
              ncol(contributions)),
  Countries = c(
    n_distinct(project_summary$PooledFundName),
    n_distinct(project_summary_location$PooledFundName),
    n_distinct(pipeline_summary$PooledFundName),
    n_distinct(pipeline_cluster$PooledFundName),
    n_distinct(clusters$PooledFundName),
    n_distinct(contributions$PooledFundName)
  ),
  `Earliest Entry` = c(
    safe_date_format(project_summary$ProjectStartDate, min),
    safe_date_format(project_summary_location$ActualStartDate, min),
    safe_date_format(pipeline_summary$ActualStartDate, min),
    safe_year_format(pipeline_cluster$AllocationYear, min),
    safe_year_format(clusters$AllocationYear, min),
    safe_date_format(mdy_hms(contributions$PledgeDate), min)
  ),
  `Latest Entry` = c(
    safe_date_format(project_summary$ProjectStartDate, max),
    safe_date_format(project_summary_location$ActualStartDate, max),
    safe_date_format(pipeline_summary$ActualStartDate, max),
    safe_year_format(pipeline_cluster$AllocationYear, max),
    safe_year_format(clusters$AllocationYear, max),
    safe_date_format(mdy_hms(contributions$PledgeDate), max)
  )
)

kable(data_summary, 
      format.args = list(big.mark = ","),
      caption = "Overview of Available Datasets")
Overview of Available Datasets
Dataset Rows Columns Countries Earliest Entry Latest Entry
Project Summary 16,304 52 28 July 28, 2010 December 31, 2025
Project Summary (Location) 30,468 30 28 July 28, 2010 December 28, 2025
Pipeline Summary 577 24 17 March 02, 2002 December 29, 2025
Pipeline Cluster 1,688 10 25 2012 2025
Clusters 23,049 11 28 2010 2025
Contributions 4,202 23 31 May 28, 2008 December 22, 2025

2.1.1 Pipeline Data Note

Critical Data Limitation: Pipeline Data

The data site offers an endpoint to retrieve projects on the pipeline (under review, cancelled, etc…). After multiple attempts to retrieve comprehensive pipeline data from the CBPF API, it was confirmed that the PipelineProjectSummary endpoint returns only 581 entries from scattered years (2013-2015, 2022, 2025-2026), representing just a fraction of all project submissions across the CBPF system’s history, and with inconsistencies (including an anomalous pre-2010 entry predating the formal CBPF system).

  • Ukraine-specific: Only 1 entry from 2022 allocation (out of 413 approved Ukraine projects (2019–2025) in ProjectSummary)
  • Cannot calculate meaningful approval/rejection rates: Missing data prevents comprehensive submission vs approval analysis

What this means: - We cannot analyze Ukraine’s proposal-to-approval ratio - We cannot determine how many Ukraine projects were rejected or cancelled - We cannot compare Ukraine’s approval success rate to other pooled funds - Analysis must focus on approved projects from the ProjectSummary dataset

3 Global summary

OCHA has operated country-based pooled funds in more than 40 countries, with some locations hosting separate cross-border or regional mechanisms. The dataset used here covers the 28 pooled funds active in the 2010–2025 period, including the Syria Cross-border mechanism and two Regional Humanitarian Pooled Funds (Chad RhPF, Mozambique RhPF).

The data shows the humanitarian gap visible, in recent years, the number of countries receiving funding has increased, but the total budget allocated has gone in the wrong direction.

Show code
# Aggregate data by year - separate Ukraine from other countries
# Keep all years (2010-2025) to show pilot phase vs mature operational period
global_summary_detailed <- project_summary %>%
  filter(!is.na(AllocationYear), !is.na(Budget)) %>%
  mutate(Country_Group = if_else(PooledFundName == "Ukraine", "Ukraine", "Other Countries")) %>%
  group_by(AllocationYear, Country_Group) %>%
  summarise(Budget = sum(Budget, na.rm = TRUE), .groups = 'drop') %>%
  arrange(AllocationYear, Country_Group)

# Get total budget by year for labels
global_totals <- global_summary_detailed %>%
  group_by(AllocationYear) %>%
  summarise(
    Total_Budget = sum(Budget),
    .groups = 'drop'
  )

# Get country count by year for the line
country_counts <- project_summary %>%
  filter(!is.na(AllocationYear)) %>%
  group_by(AllocationYear) %>%
  summarise(Countries = n_distinct(PooledFundName), .groups = 'drop')

# Merge totals with country counts
global_summary <- global_totals %>%
  left_join(country_counts, by = "AllocationYear")

# Create scaling factor for secondary axis
scale_factor <- max(global_summary$Total_Budget) / max(global_summary$Countries) / 1e6

# Create combination chart with Ukraine highlighted
ggplot() +
  # Stacked bars for budget (Ukraine in yellow, others in teal)
  geom_col(data = global_summary_detailed,
           aes(x = as.factor(AllocationYear), y = Budget/1e6, fill = Country_Group),
           alpha = 0.8, width = 0.7) +
  # Line for countries
  geom_line(data = global_summary,
            aes(x = as.factor(AllocationYear), y = Countries * scale_factor, group = 1),
            color = "#e63946", linewidth = 1.2) +
  geom_point(data = global_summary,
             aes(x = as.factor(AllocationYear), y = Countries * scale_factor),
             color = "#e63946", size = 3) +
  # Labels at top of bars
  geom_text(data = global_summary,
            aes(x = as.factor(AllocationYear), y = Total_Budget/1e6,
                label = paste0("$", round(Total_Budget/1e6, 0), "M")),
            vjust = -0.5, size = 3.5, fontface = "bold", color = "#333333") +
  # Color scale
  scale_fill_manual(
    values = c("Ukraine" = "#f4a261", "Other Countries" = "#005f73"),
    name = "Region"
  ) +
  # Primary y-axis
  scale_y_continuous(
    name = "Total Budget Allocated (Million USD)",
    labels = scales::comma,
    limits = c(0, NA),
    expand = expansion(mult = c(0, 0.1)),
    # Secondary y-axis
    sec.axis = sec_axis(~ . / scale_factor, 
                        name = "Number of Countries",
                        breaks = scales::pretty_breaks(n = 6))
  ) +
  labs(
    title = "Global CBPF Funding Allocation and Country Coverage by Year (2010-2025)",
    subtitle = "Budget shown in bars (Ukraine highlighted in yellow), country count shown as line • Pilot phase (2010-2013) shows limited scale",
    x = "Allocation Year"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 16),
    plot.subtitle = element_text(size = 12, color = "#666666"),
    axis.title.y.left = element_text(color = "#005f73", face = "bold", size = 12),
    axis.title.y.right = element_text(color = "#e63946", face = "bold", size = 12),
    axis.text = element_text(size = 11),
    axis.text.x = element_text(angle = 45, hjust = 1),
    panel.grid.minor = element_blank(),
    legend.position = "bottom",
    legend.title = element_text(face = "bold")
  )

The 2010-2013 period represents the early pilot phase of CBPF with limited geographic coverage (primarily one country) and significantly lower funding levels. A major scale-up occurred in 2014 when the system expanded to multiple countries and reached operational maturity. To ensure data comparability, subsequent analysis focuses on the mature operational period from 2014 to 2025.

3.1 Budget Distribution by Country (2022-2025)

This visualization shows how CBPF funding was distributed across countries for each year from 2022 to 2025. Ukraine is highlighted in yellow to track its position among the top recipients throughout this period.

Show code
# Get top countries per year
country_budget_yearly <- project_summary %>%
  filter(AllocationYear >= 2022, AllocationYear <= 2025) %>%
  filter(!is.na(Budget), !is.na(PooledFundName)) %>%
  group_by(AllocationYear, PooledFundName) %>%
  summarise(
    Total_Budget = sum(Budget, na.rm = TRUE),
    Projects = n(),
    .groups = 'drop'
  )

# Get top 12 countries per year (or include Ukraine if not in top 12)
top_countries_per_year <- country_budget_yearly %>%
  group_by(AllocationYear) %>%
  arrange(desc(Total_Budget)) %>%
  slice_head(n = 12) %>%
  ungroup()

# Ensure Ukraine is included in all years
ukraine_data <- country_budget_yearly %>%
  filter(PooledFundName == "Ukraine")

# Combine and remove duplicates
country_budget_yearly_filtered <- bind_rows(top_countries_per_year, ukraine_data) %>%
  distinct(AllocationYear, PooledFundName, .keep_all = TRUE)

# Add highlighting, labels, and ordering within each year
country_budget_yearly_filtered <- country_budget_yearly_filtered %>%
  mutate(
    Highlight = if_else(PooledFundName == "Ukraine", "Ukraine", "Other")
  ) %>%
  group_by(AllocationYear) %>%
  mutate(
    Rank = rank(-Total_Budget),
    Label = if_else(Rank <= 8 | PooledFundName == "Ukraine",
                    paste0("$", round(Total_Budget/1e6, 0), "M"),
                    "")
  ) %>%
  ungroup() %>%
  # Create a compound key for proper ordering within facets
  mutate(
    Year_Country = paste(AllocationYear, PooledFundName, sep = "_"),
    Year_Country = reorder(Year_Country, Total_Budget)
  )

# Create faceted horizontal bar chart
ggplot(country_budget_yearly_filtered, 
       aes(x = Total_Budget/1e6, 
           y = Year_Country)) +
  geom_col(aes(fill = Highlight), alpha = 0.85, width = 0.75) +
  geom_text(aes(label = Label), hjust = -0.1, size = 3, fontface = "bold", color = "#333333") +
  scale_fill_manual(
    values = c("Ukraine" = "#f4a261", "Other" = "#005f73"),
    guide = "none"
  ) +
  scale_y_discrete(labels = function(x) gsub("^\\d{4}_", "", x)) +
  scale_x_continuous(
    labels = scales::comma,
    expand = expansion(mult = c(0, 0.15))
  ) +
  facet_wrap(~ AllocationYear, ncol = 2, scales = "free_y") +
  labs(
    title = "Top Countries by CBPF Budget Allocation per Year (2022-2025)",
    subtitle = "Ukraine highlighted in yellow • Shows top recipients each year and how rankings shift",
    x = "Budget (Million USD)",
    y = NULL
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 16, margin = margin(b = 5)),
    plot.subtitle = element_text(size = 11, color = "#666666", margin = margin(b = 15)),
    axis.text.y = element_text(size = 9.5),
    axis.text.x = element_text(size = 9),
    axis.title.x = element_text(face = "bold", size = 11, margin = margin(t = 10)),
    strip.text = element_text(face = "bold", size = 13, margin = margin(b = 10)),
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank(),
    panel.spacing = unit(1.5, "lines"),
    plot.margin = margin(15, 15, 15, 15)
  )

Afghanistan is the only country that has received more funding than Ukraine in 2022, this is due to commitments made at the global level following the Taliban takeover, probably made earlier than February 2022 as contributions to the appeal.

Also note, that this cannot be taken as an indicator of the global humanitarian attention, CBPFs is a flexible global mechanism that can balance the funding situation between different crises, considering the attention given by other donors.

4 Allocation Flow Analysis

Country-Based Pool Funds have the two ways of publish allocations: the standard allocation and the reserve allocation.

  • Standard Allocation: Regular funding cycles based on strategic response plans and humanitarian response planning
  • Reserve Allocation: Emergency funding for urgent needs, rapid response to sudden onset crises, and critical gaps

Key Differences from Standard Allocations:

  • Speed: The timeline is compressed. A Reserve Allocation can be processed from launch to signature in as little as 48-72 hours in extreme cases, though 2-3 weeks is more common.
  • Targeting: The strategy is highly prescriptive. The HC may define not just the geographic area but the specific activities required (e.g., “Provision of emergency shelter kits in District X”).
  • Partner Selection: While Standard Allocations are open calls, Reserve Allocations can be “closed” or restricted. The HC may invite specific partners who are known to have presence and capacity in the affected area to submit proposals, thereby bypassing the open competitive process to save time.

4.1 Budget Distribution by Allocation Source

UHF is among the heaviest users of the reserve allocation in the CBPF system, with Reserve accounting for 51.9 % of its 2019–2025 budget — well above the non-Ukraine average of 40.2 % and placing Ukraine 7th of 28 funds on this metric (alongside Sudan, oPt and DRC). This chart compares the distribution of standard vs reserve allocations for Ukraine against the rest of the world, showing that while Ukraine has a higher reliance on reserve allocations, there is a trend towards more balanced funding in 2025.

Show code
# Ukraine allocation summary
ukraine_allocation_summary <- project_summary %>%
  filter(AllocationYear >= 2014, 
         !is.na(AllocationSourceName),
         PooledFundName == "Ukraine") %>%
  group_by(AllocationSourceName) %>%
  summarise(
    Projects = n(),
    Budget = sum(Budget, na.rm = TRUE),
    .groups = 'drop'
  ) %>%
  mutate(
    Region = "Ukraine",
    # Force factor ordering: Standard first, Reserve second
    AllocationSourceName = factor(AllocationSourceName, levels = c("Standard", "Reserve"))
  ) %>%
  arrange(AllocationSourceName)

# Rest of World allocation summary (excluding Ukraine)
rest_of_world_summary <- project_summary %>%
  filter(AllocationYear >= 2014, 
         !is.na(AllocationSourceName),
         PooledFundName != "Ukraine") %>%
  group_by(AllocationSourceName) %>%
  summarise(
    Projects = n(),
    Budget = sum(Budget, na.rm = TRUE),
    .groups = 'drop'
  ) %>%
  mutate(
    Region = "Rest of World",
    # Force factor ordering: Standard first, Reserve second
    AllocationSourceName = factor(AllocationSourceName, levels = c("Standard", "Reserve"))
  ) %>%
  arrange(AllocationSourceName)

# Combine both datasets and ensure ordering
comparison_data <- bind_rows(ukraine_allocation_summary, rest_of_world_summary) %>%
  arrange(Region, AllocationSourceName)

# Recalculate percentages within each region
comparison_data <- comparison_data %>%
  group_by(Region) %>%
  mutate(
    Projects_Pct = round((Projects / sum(Projects)) * 100, 1),
    Budget_Pct = round((Budget / sum(Budget)) * 100, 1)
  ) %>%
  ungroup() %>%
  arrange(Region, AllocationSourceName)

# Budget proportions chart - Standard at bottom (dark teal), Reserve on top (light teal)
ggplot(comparison_data, 
       aes(x = Region, y = Budget_Pct, fill = AllocationSourceName)) +
  geom_col(position = position_stack(reverse = FALSE), width = 0.6) +
  geom_text(aes(label = paste0(AllocationSourceName, "\n", Budget_Pct, "%\n($", round(Budget/1e6, 0), "M)")),
            position = position_stack(vjust = 0.5, reverse = FALSE),
            color = "white",
            fontface = "bold",
            size = 3.8,
            lineheight = 0.85) +
  scale_fill_manual(values = c("Standard" = "#005f73", "Reserve" = "#94d2bd"),
                    name = "Allocation Type",
                    breaks = c("Standard", "Reserve")) +
  scale_y_continuous(labels = function(x) paste0(x, "%"),
                     expand = expansion(mult = c(0, 0.02))) +
  labs(
    title = "Budget Distribution by Allocation Type: Ukraine vs Rest of World (2014-2025)",
    subtitle = "Standard allocations (dark teal) at bottom, Reserve allocations (light teal) on top",
    x = NULL,
    y = "Percentage of Budget"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(size = 11, color = "#666666"),
    panel.grid.major.x = element_blank(),
    axis.text.x = element_text(size = 12, face = "bold"),
    legend.position = "top",
    legend.text = element_text(size = 11, face = "bold")
  )

Some allocation rounds that partners perceived as reserved are recorded in the CBPF database as standard allocations. For example, the 2024 Kharkiv area-based allocation was treated by some partners as effectively reserved, as some eligible organizations were reportedly advised not to submit proposals. In the source data, however, these rounds are classified and counted as standard.

Show code
# Calculate budget by allocation source and year for UKRAINE only (2019-2025)
ukraine_allocation_by_year <- project_summary %>%
  filter(PooledFundName == "Ukraine", 
         AllocationYear >= 2019,
         !is.na(AllocationSourceName)) %>%
  group_by(AllocationYear, AllocationSourceName) %>%
  summarise(Total_Budget = sum(Budget, na.rm = TRUE), .groups = 'drop') %>%
  # Force factor ordering BEFORE calculating percentages
  mutate(
    AllocationSourceName = factor(AllocationSourceName, levels = c("Standard", "Reserve"))
  ) %>%
  arrange(AllocationYear, AllocationSourceName) %>%
  group_by(AllocationYear) %>%
  mutate(
    Year_Total = sum(Total_Budget),
    Percentage = round((Total_Budget / Year_Total) * 100, 1)
  ) %>%
  ungroup() %>%
  arrange(AllocationYear, AllocationSourceName)

# Visualization - line chart showing percentage over time
# Convert to millions for display
ukraine_allocation_by_year <- ukraine_allocation_by_year %>%
  mutate(Budget_Millions = Total_Budget / 1e6)

ggplot(ukraine_allocation_by_year, 
       aes(x = AllocationYear, y = Percentage, color = AllocationSourceName, group = AllocationSourceName)) +
  geom_line(linewidth = 1.5) +
  geom_point(size = 4) +
  geom_text(aes(label = paste0(Percentage, "%")),
            vjust = -1.2, 
            size = 3.5,
            fontface = "bold",
            show.legend = FALSE) +
  scale_color_manual(values = c("Standard" = "#005f73", "Reserve" = "#94d2bd"),
                     name = "Allocation Type",
                     breaks = c("Standard", "Reserve")) +
  scale_y_continuous(labels = function(x) paste0(x, "%"),
                     limits = c(0, 100),
                     breaks = seq(0, 100, 20),
                     expand = expansion(mult = c(0.05, 0.15))) +
  scale_x_continuous(breaks = seq(2019, 2025, 1)) +
  labs(
    title = "Ukraine Allocation Type Distribution Over Time (2019-2025)",
    subtitle = "Percentage of budget allocated through Standard vs Reserve modalities by year",
    x = "Allocation Year",
    y = "Percentage of Total Budget"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(size = 11, color = "#666666"),
    legend.position = "top",
    legend.text = element_text(size = 11, face = "bold"),
    panel.grid.minor = element_blank(),
    axis.text.x = element_text(size = 11)
  )

Although the balance between standard and reserve allocations appears to be correcting in 2025, it is important to note that since the counter-offensives of 2022, there have not been major events — such as large-scale population movements or recaptured territories — that would justify the recurrent use of the reserve modality. Ukraine’s humanitarian needs can be considered sustainably high, but not necessarily volatile.

The global CBPF guidelines state that only exceptionally should the reserve allocation be used for non-emergency purposes: “the Reserve Allocation may also be used to support special initiatives which may not be rapid onset or unforeseen per se…” (CBPF Global Guidelines, art. 136).

4.2 Budget Distribution by Type of Organization

CBPF allocations reach beneficiaries through four types of implementing partners: International NGOs (INGOs), National NGOs (NNGOs), UN Agencies, and a residual “Others” category. The balance between these partner types reflects both operational realities on the ground and broader policy commitments — notably the Grand Bargain pledge to channel at least 25% of humanitarian funding to local and national actors.

Show code
org_colors <- c("National NGO"      = "#2a9d8f",
                "International NGO" = "#005f73",
                "UN Agency"         = "#e9c46a",
                "Others"            = "#adb5bd")

# --- Left panel: proportional comparison Ukraine vs Rest of World ---
org_type_ukraine <- project_summary %>%
  filter(AllocationYear >= 2014, !is.na(OrganizationType),
         PooledFundName == "Ukraine") %>%
  group_by(OrganizationType) %>%
  summarise(Budget = sum(Budget, na.rm = TRUE), Projects = n(), .groups = "drop") %>%
  mutate(Region = "Ukraine")

org_type_row <- project_summary %>%
  filter(AllocationYear >= 2014, !is.na(OrganizationType),
         PooledFundName != "Ukraine") %>%
  group_by(OrganizationType) %>%
  summarise(Budget = sum(Budget, na.rm = TRUE), Projects = n(), .groups = "drop") %>%
  mutate(Region = "Rest of World")

org_type_comparison <- bind_rows(org_type_ukraine, org_type_row) %>%
  group_by(Region) %>%
  mutate(
    Budget_Pct = round((Budget / sum(Budget)) * 100, 1),
    OrganizationType = factor(OrganizationType, levels = names(org_colors))
  ) %>%
  ungroup()

p_comparison <- ggplot(org_type_comparison,
       aes(x = Region, y = Budget_Pct, fill = OrganizationType)) +
  geom_col(position = position_stack(reverse = TRUE), width = 0.55) +
  geom_text(aes(label = paste0(OrganizationType, "\n", Budget_Pct, "%")),
            position = position_stack(vjust = 0.5, reverse = TRUE),
            color = "white", fontface = "bold", size = 3.2, lineheight = 0.9) +
  scale_fill_manual(values = org_colors, name = "Organization Type") +
  scale_y_continuous(labels = function(x) paste0(x, "%"),
                     expand = expansion(mult = c(0, 0.02))) +
  labs(title = "Ukraine vs Rest of World",
       subtitle = "Budget share by org type (2014-2025)",
       x = NULL, y = "Percentage of Budget") +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 13),
    plot.subtitle = element_text(size = 10, color = "#666666"),
    panel.grid.major.x = element_blank(),
    axis.text.x = element_text(size = 11, face = "bold"),
    legend.position = "none"
  )

# --- Right panel: Ukraine absolute budget over time ---
ukraine_org_year <- project_summary %>%
  filter(PooledFundName == "Ukraine", AllocationYear >= 2022,
         !is.na(OrganizationType)) %>%
  group_by(AllocationYear, OrganizationType) %>%
  summarise(Budget = sum(Budget, na.rm = TRUE), .groups = "drop") %>%
  mutate(OrganizationType = factor(OrganizationType, levels = names(org_colors)))

p_over_time <- ggplot(ukraine_org_year,
       aes(x = as.factor(AllocationYear), y = Budget / 1e6, fill = OrganizationType)) +
  geom_col(position = position_stack(reverse = TRUE), width = 0.7, alpha = 0.9) +
  scale_fill_manual(values = org_colors, name = "Organization Type") +
  scale_y_continuous(labels = scales::comma, expand = expansion(mult = c(0, 0.08))) +
  labs(title = "Ukraine Budget by Organization Type Over Time",
       subtitle = "USD millions (2022-2025)",
       x = "Allocation Year", y = "Budget (USD millions)") +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 13),
    plot.subtitle = element_text(size = 10, color = "#666666"),
    axis.text.x = element_text(size = 9, angle = 45, hjust = 1),
    legend.position = "top",
    legend.text = element_text(size = 9)
  )

p_comparison + p_over_time + plot_layout(widths = c(1, 2))

4.3 Budget Distribution by Sector (Cluster)

This section examines how humanitarian funding is distributed across sectors, comparing the global CBPF portfolio with Ukraine’s UHF allocations (2014–2025). Sector labels in the CBPF system correspond to inter-agency clusters (e.g., Health, WASH, Protection).

4.3.1 Global vs Ukraine: Sector Budget Share

Show code
# Colour palette: one shade per source
source_colors <- c("Global CBPF" = "#94d2bd", "Ukraine UHF" = "#005f73")

ggplot(comparison_df,
       aes(x = Cluster, y = Share, fill = Source)) +
  geom_col(position = position_dodge(width = 0.75), width = 0.65, alpha = 0.9) +
  geom_text(aes(label = paste0(round(Share, 1), "%")),
            position = position_dodge(width = 0.75),
            hjust = -0.15, size = 3.2, fontface = "bold") +
  coord_flip() +
  scale_fill_manual(values = source_colors, name = NULL) +
  scale_y_continuous(
    labels = function(x) paste0(x, "%"),
    expand = expansion(mult = c(0, 0.25))
  ) +
  labs(
    title = "Sector Budget Share: Global CBPF vs Ukraine UHF (2014–2025)",
    subtitle = "Percentage of total allocated budget per sector",
    x = NULL, y = "Share of Total Budget (%)",
    caption = "Source: CBPF Cluster dataset, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "top",
    legend.text   = element_text(size = 11, face = "bold"),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank(),
    axis.text.y        = element_text(size = 11)
  )

Key observations: Emergency Shelter & NFI and Protection dominate Ukraine’s portfolio—reflecting the conflict-driven displacement crisis—while globally WASH and Health are the largest sectors. Ukraine’s WASH and Health shares are notably lower than the global average, highlighting a context-specific funding pattern.

4.3.2 Ukraine Sector Budget Evolution Over Time

The stacked view below tracks how the Ukraine UHF cluster mix has evolved year-on-year since the full-scale invasion. Health is highlighted in red — its absolute envelope has grown, but its share of the portfolio remains compressed by the scale of Protection and Shelter / NFI spending.

Show code
# Yearly breakdown for Ukraine per cluster
ukraine_yearly <- clusters_clean %>%
  filter(PooledFundName == "Ukraine", AllocationYear >= 2022) %>%
  group_by(AllocationYear, Cluster) %>%
  summarise(Budget_M = sum(ClusterBudget, na.rm = TRUE) / 1e6, .groups = "drop")

sector_palette <- c(
  "WASH"                         = "#0077B6",
  "Health"                       = "#e63946",
  "Food Security"                = "#90E0EF",
  "Emergency Shelter & NFI"      = "#005f73",
  "Protection"                   = "#0A9396",
  "Nutrition"                    = "#94D2BD",
  "Education"                    = "#E9C46A",
  "Camp Coordination/Mgmt"       = "#F4A261",
  "Multi-purpose CASH"           = "#E76F51",
  "Coord. & Support Services"    = "#ae2012",
  "Multi-Sector"                 = "#9b2226",
  "Early Recovery"               = "#6d6875",
  "Logistics"                    = "#b5b5b5",
  "Emergency Telecoms"           = "#d4d4d4",
  "COVID-19"                     = "#ccc5b9"
)

ukraine_yearly <- ukraine_yearly %>%
  mutate(Cluster = factor(Cluster, levels = rev(cluster_order)))

ggplot(ukraine_yearly,
       aes(x = as.factor(AllocationYear), y = Budget_M, fill = Cluster)) +
  geom_col(position = position_stack(reverse = TRUE), width = 0.75, alpha = 0.92) +
  scale_fill_manual(values = sector_palette, name = "Sector") +
  scale_y_continuous(
    labels = scales::comma,
    expand = expansion(mult = c(0, 0.06))
  ) +
  labs(
    title = "Ukraine UHF: Sector Budget Distribution Over Time (2022–2025)",
    subtitle = "USD millions, stacked by cluster  |  \u2665 Health sector highlighted",
    x = "Allocation Year", y = "Budget (USD millions)",
    caption = "Source: CBPF Cluster dataset, Feb 2026 extract"
  ) +
  guides(fill = guide_legend(nrow = 3, byrow = TRUE)) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "bottom",
    legend.text   = element_text(size = 9),
    panel.grid.minor   = element_blank(),
    axis.text.x        = element_text(size = 10, angle = 45, hjust = 1)
  )

4.3.3 Divergence from Global Average

Show code
# Calculate difference: Ukraine share minus global share
divergence_df <- ukraine_cluster %>%
  select(Cluster, Ukraine_Share = Share) %>%
  left_join(
    global_cluster %>% select(Cluster, Global_Share = Share),
    by = "Cluster"
  ) %>%
  mutate(
    Global_Share  = replace_na(Global_Share, 0),
    Diff          = Ukraine_Share - Global_Share,
    Direction     = if_else(Diff >= 0, "Above global average", "Below global average"),
    Cluster       = factor(Cluster, levels = cluster_order)
  )

ggplot(divergence_df,
       aes(x = Cluster, y = Diff, fill = Direction)) +
  geom_col(width = 0.7, alpha = 0.9) +
  geom_hline(yintercept = 0, linewidth = 0.8, color = "grey30") +
  geom_text(aes(label = paste0(if_else(Diff > 0, "+", ""), round(Diff, 1), " pp"),
                hjust = if_else(Diff >= 0, -0.1, 1.1)),
            size = 3.4, fontface = "bold") +
  coord_flip() +
  scale_fill_manual(
    values = c("Above global average" = "#005f73", "Below global average" = "#e76f51"),
    name = NULL
  ) +
  scale_y_continuous(
    labels = function(x) paste0(x, " pp"),
    expand = expansion(mult = c(0.25, 0.25))
  ) +
  labs(
    title = "Ukraine UHF Sector Share vs Global CBPF Average (2014–2025)",
    subtitle = "Percentage-point difference (Ukraine minus global); pp = percentage points",
    x = NULL, y = "Difference (percentage points)",
    caption = "Source: CBPF Cluster dataset, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "top",
    legend.text   = element_text(size = 11, face = "bold"),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank(),
    axis.text.y        = element_text(size = 11)
  )

5 Grant Characteristics and Patterns

This chapter examines how large individual grants are across the CBPF system, comparing global patterns with Ukraine. Grant size reflects strategic choices: larger grants reduce transaction costs but concentrate risk; smaller grants enable broader partner diversity.

5.1 Grant Size Distribution

5.1.1 Grant Size: Global vs Ukraine

The density plot below uses a log₁₀ scale to handle the wide spread of grant values (from ~$25K to $29M). Both distributions are right-skewed; the key question is whether Ukraine’s curve sits systematically to the right.

Show code
# Summary stats for annotation
medians <- grants %>%
  group_by(Context) %>%
  summarise(med = median(Budget), .groups = "drop")

fmt_usd <- function(x) {
  ifelse(x >= 1e6,
    paste0("$", formatC(x / 1e6, format = "f", digits = 2), "M"),
    paste0("$", scales::comma(round(x / 1e3)), "K")
  )
}

ggplot(grants, aes(x = Budget, fill = Context, color = Context)) +
  geom_density(alpha = 0.35, linewidth = 0.9, adjust = 0.8) +
  geom_vline(data = medians,
             aes(xintercept = med, color = Context),
             linetype = "dashed", linewidth = 1.1) +
  geom_label(data = medians,
             aes(x = med, y = Inf,
                 label = paste0("Median\n", fmt_usd(med)),
                 color = Context),
             vjust = 1.3, hjust = -0.08, size = 3.5, fontface = "bold",
             fill = "white", label.size = 0.3, show.legend = FALSE) +
  scale_x_log10(
    labels = scales::label_dollar(scale_cut = scales::cut_short_scale()),
    breaks = c(1e4, 5e4, 1e5, 5e5, 1e6, 5e6, 1e7)
  ) +
  scale_fill_manual(values  = ctx_colors, name = NULL) +
  scale_color_manual(values = ctx_colors, name = NULL) +
  labs(
    title    = "Grant Size Distribution: Global CBPF vs Ukraine UHF (2014–2025)",
    subtitle = "Log₁₀ scale — dashed lines mark medians. Ukraine grants are substantially larger.",
    x = "Grant size (USD, log scale)", y = "Density",
    caption = "Source: CBPF ProjectSummary dataset, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "top",
    legend.text   = element_text(size = 11, face = "bold"),
    panel.grid.minor = element_blank()
  )

Ukraine grants are 2.6× larger at the median — $1.13M vs $440K globally.

There is no available data on grant underspending. As a result, it is unclear how often partners do not fully meet their commitments while continuing to receive funding.

5.1.2 INGO vs NNGO Grant Size: A Deeper Look

Globally (excluding Ukraine), INGOs receive grants about 25 % larger than NNGOs at the median (ratio ≈ 1.25×). Ukraine’s ratio (1.40×) is mid-table but masks a striking post-2022 shift.

5.1.3 How Does Ukraine Compare Across All Funds?

Show code
# Per-fund medians for INGO and NNGO (min 10 grants each)
fund_ngotype <- project_summary %>%
  filter(AllocationYear >= 2014,
         !is.na(Budget), Budget > 0,
         OrganizationType %in% c("International NGO", "National NGO")) %>%
  group_by(PooledFundName, OrganizationType) %>%
  summarise(med = median(Budget), n = n(), .groups = "drop")

fund_wide <- fund_ngotype %>%
  pivot_wider(names_from = OrganizationType, values_from = c(med, n)) %>%
  rename(ingo_med = `med_International NGO`,
         nngo_med = `med_National NGO`,
         ingo_n   = `n_International NGO`,
         nngo_n   = `n_National NGO`) %>%
  filter(!is.na(ingo_med), !is.na(nngo_med),
         ingo_n >= 10, nngo_n >= 10) %>%
  mutate(
    ratio     = ingo_med / nngo_med,
    is_ukraine = PooledFundName == "Ukraine",
    PooledFundName = factor(PooledFundName, levels = PooledFundName[order(ratio)])
  )

# Long form for dumbbell
dumbbell_df <- fund_wide %>%
  select(PooledFundName, ingo_med, nngo_med, ratio, is_ukraine) %>%
  pivot_longer(cols = c(ingo_med, nngo_med),
               names_to = "OrgType", values_to = "Median") %>%
  mutate(OrgType = if_else(OrgType == "ingo_med", "INGO", "NNGO"))

ggplot() +
  # segment connecting NNGO to INGO per fund
  geom_segment(
    data = fund_wide,
    aes(x = nngo_med / 1e3, xend = ingo_med / 1e3,
        y = PooledFundName, yend = PooledFundName,
        color = is_ukraine),
    linewidth = 1.2, alpha = 0.7
  ) +
  # dots for each org type
  geom_point(
    data = dumbbell_df,
    aes(x = Median / 1e3, y = PooledFundName,
        shape = OrgType, color = is_ukraine),
    size = 3.5, stroke = 1.0
  ) +
  # ratio label to the right
  geom_text(
    data = fund_wide,
    aes(x = pmax(ingo_med, nngo_med) / 1e3,
        y = PooledFundName,
        label = paste0(round(ratio, 2), "×"),
        fontface = if_else(is_ukraine, "bold", "plain")),
    hjust = -0.25, size = 3.1, color = "grey30"
  ) +
  scale_x_continuous(
    labels = scales::dollar_format(suffix = "K", accuracy = 1),
    expand = expansion(mult = c(0.02, 0.18))
  ) +
  scale_color_manual(
    values = c("TRUE" = "#f4a261", "FALSE" = "#94d2bd"),
    guide  = "none"
  ) +
  scale_shape_manual(values = c("INGO" = 16, "NNGO" = 1), name = NULL) +
  labs(
    title    = "Median Grant Size: INGO vs NNGO per CBPF Fund (2014–2025)",
    subtitle = "Sorted by INGO/NNGO ratio (right labels)  |  <b style='color:#f4a261'>Ukraine highlighted</b>  |  filled = INGO, open = NNGO",
    x = "Median grant size (USD thousands)", y = NULL,
    caption = "Funds with < 10 grants per org type excluded"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_markdown(size = 10, color = "#555555"),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank(),
    legend.position    = "top",
    legend.text        = element_text(size = 11, face = "bold"),
    axis.text.y        = element_text(
      size  = ifelse(levels(fund_wide$PooledFundName) == "Ukraine", 12, 10),
      face  = ifelse(levels(fund_wide$PooledFundName) == "Ukraine", "bold", "plain"),
      color = ifelse(levels(fund_wide$PooledFundName) == "Ukraine", "#f4a261", "grey30")
    )
  )

What the chart presents: Each horizontal segment represents one CBPF fund. The open circle is the NNGO median grant, the filled circle is the INGO median. The further right, the larger the absolute grants. The ratio label is what matters for the comparison.

The headline finding: Ukraine’s ratio is unremarkable At 1.40×, Ukraine sits mid-pack — 14th of 24 funds that have at least 10 INGO and 10 NNGO grants in 2014–2025. The figure is close to the non-Ukraine global benchmark (≈1.25×) and far from the high-ratio contexts (Sudan 2.26×, Nigeria 2.00×). This is counterintuitive given Ukraine’s reputation as a progressive localization context. The ratio alone doesn’t signal any particular INGO advantage.

But the absolute values tell a different story Ukraine’s NNGO median (~$999K) is likely higher in absolute terms than many funds’ INGO medians. A Ukrainian NNGO receives grants that would be considered large even by INGO standards in Sudan or Nigeria. The localization story in Ukraine is not just about equality of ratio — it’s that a smaller group of persisting selections operates at a much higher scale.

The implication for Ukraine: The ratio of 1.40× aggregated over 2014–2025 understates how much has changed recently — that’s exactly what the trend chart below it reveals, where the ratio collapsed to 0.84× in 2024. The aggregate figure is dragged up by the early pre-war years when Ukraine operated more like a typical fund.

5.1.4 Ukraine: INGO/NNGO Grant Ratio Over Time

Since 2022, Ukraine’s NNGO grants have grown faster than INGO grants. In 2023 and 2024 the ratio fell below 1 — NNGOs received larger median grants than INGOs — a direct signal of the localization push intensifying after the full-scale invasion.

Show code
# Year-by-year ratio for Ukraine and global average
ngo_ratio_all <- project_summary %>%
  filter(AllocationYear >= 2014,
         !is.na(Budget), Budget > 0,
         OrganizationType %in% c("International NGO", "National NGO")) %>%
  group_by(PooledFundName, AllocationYear, OrganizationType) %>%
  summarise(med = median(Budget), n = n(), .groups = "drop")

make_ratio <- function(df) {
  df %>%
    pivot_wider(names_from = OrganizationType, values_from = c(med, n)) %>%
    rename(ingo = `med_International NGO`, nngo = `med_National NGO`,
           n_i  = `n_International NGO`,  n_n  = `n_National NGO`) %>%
    filter(!is.na(ingo), !is.na(nngo), n_i >= 5, n_n >= 5) %>%
    mutate(ratio = ingo / nngo)
}

ukraine_ratio <- ngo_ratio_all %>%
  filter(PooledFundName == "Ukraine") %>%
  make_ratio() %>%
  mutate(Series = "Ukraine UHF")

global_ratio <- ngo_ratio_all %>%
  filter(PooledFundName != "Ukraine") %>%
  group_by(AllocationYear, OrganizationType) %>%
  summarise(med = median(med), n = sum(n), .groups = "drop") %>%
  mutate(PooledFundName = "Global") %>%
  make_ratio() %>%
  mutate(Series = "Global CBPF average")

ratio_trend <- bind_rows(ukraine_ratio, global_ratio)

series_colors <- c("Ukraine UHF" = "#005f73", "Global CBPF average" = "#94d2bd")

ggplot(ratio_trend,
       aes(x = AllocationYear, y = ratio, color = Series, group = Series)) +
  geom_hline(yintercept = 1, linetype = "dashed", color = "#e63946",
             linewidth = 0.9) +
  annotate("text", x = 2014.2, y = 1.03,
           label = "Parity (INGO = NNGO)", color = "#e63946",
           size = 3.4, hjust = 0, fontface = "italic") +
  geom_ribbon(
    data = ratio_trend %>% filter(Series == "Ukraine UHF"),
    aes(ymin = 1, ymax = ratio, fill = ratio < 1),
    alpha = 0.15, color = NA
  ) +
  geom_line(linewidth = 1.5) +
  geom_point(size = 4) +
  geom_text(aes(label = round(ratio, 2)),
            vjust = -1.1, size = 3.3, fontface = "bold",
            show.legend = FALSE) +
  scale_color_manual(values = series_colors, name = NULL) +
  scale_fill_manual(values = c("FALSE" = "#005f73", "TRUE" = "#e63946"),
                    guide = "none") +
  scale_x_continuous(breaks = 2014:2025) +
  scale_y_continuous(
    labels = function(x) paste0(x, "×"),
    breaks = seq(0.7, 1.8, 0.1)
  ) +
  labs(
    title    = "INGO/NNGO Median Grant Ratio Over Time: Ukraine vs Global (2014–2025)",
    subtitle = "Ratio > 1 means INGOs get larger grants  |  Red shading = years where NNGOs received larger grants (ratio < 1)",
    x = "Allocation Year", y = "INGO median / NNGO median",
    caption = "Global = median of fund-level medians, excluding Ukraine"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "top",
    legend.text   = element_text(size = 11, face = "bold"),
    panel.grid.minor = element_blank(),
    axis.text.x      = element_text(angle = 45, hjust = 1, size = 10)
  )

Ukraine’s localization in action: while globally the INGO size premium has been stable at ~1.2–1.4×, Ukraine’s ratio dropped to 0.94× in 2023 and 0.84× in 2024, meaning NNGOs were receiving larger individual grants than INGOs. This likely reflects deliberate fund strategy to direct larger, more complex grants to national partners after the scale-up in 2022.

5.1.5 Grant Size by Sector — Health in Context

Using each project’s primary cluster (the sector receiving the highest budget share), this chart compares median grant sizes across sectors. Health grants are highlighted in red.

Show code
# Compute median + IQR per cluster for both contexts combined
sector_stats <- grants_cl %>%
  filter(Cluster != "Unknown") %>%
  group_by(Cluster) %>%
  summarise(
    med    = median(Budget),
    q1     = quantile(Budget, 0.25),
    q3     = quantile(Budget, 0.75),
    n      = n(),
    .groups = "drop"
  ) %>%
  arrange(med) %>%
  mutate(
    Cluster    = factor(Cluster, levels = Cluster),
    is_health  = Cluster == "Health"
  )

# Separate Ukraine medians to overlay
ukraine_med <- grants_cl %>%
  filter(Cluster != "Unknown", PooledFundName == "Ukraine") %>%
  group_by(Cluster) %>%
  summarise(ukraine_med = median(Budget), .groups = "drop") %>%
  mutate(Cluster = factor(Cluster, levels = levels(sector_stats$Cluster)))

sector_plot_df <- sector_stats %>%
  left_join(ukraine_med, by = "Cluster")

ggplot(sector_plot_df,
       aes(x = Cluster, y = med / 1e3, fill = is_health)) +
  geom_col(width = 0.7, alpha = 0.88) +
  gghighlight(
    is_health,
    unhighlighted_params = list(fill = "#94d2bd", alpha = 0.6),
    use_direct_label = FALSE
  ) +
  # Ukraine medians as points
  geom_point(
    aes(y = ukraine_med / 1e3),
    shape = 23, size = 3.5, fill = "#005f73", color = "white", stroke = 0.8,
    na.rm = TRUE
  ) +
  geom_text(
    aes(label = paste0("$", scales::comma(round(med / 1e3)), "K")),
    hjust = -0.1, size = 3.2, fontface = "bold"
  ) +
  coord_flip() +
  scale_fill_manual(values = c("FALSE" = "#94d2bd", "TRUE" = "#e63946"),
                    guide = "none") +
  scale_y_continuous(
    labels = scales::dollar_format(suffix = "K", accuracy = 1),
    expand = expansion(mult = c(0, 0.3))
  ) +
  labs(
    title    = "Median Grant Size by Sector — Global CBPF (2014–2025)",
    subtitle = "<b style='color:#e63946'>♥ Health</b> highlighted  |  <b style='color:#005f73'>◆ Diamond</b> = Ukraine UHF median",
    x = NULL, y = "Median grant size (USD thousands)",
    caption = "Source: CBPF ProjectSummary + Cluster datasets, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_markdown(size = 11, color = "#555555"),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank(),
    axis.text.y        = element_markdown(
      size  = ifelse(levels(sector_plot_df$Cluster) == "Health", 12, 11),
      face  = ifelse(levels(sector_plot_df$Cluster) == "Health", "bold", "plain"),
      color = ifelse(levels(sector_plot_df$Cluster) == "Health", "#e63946", "grey30")
    )
  )

Health grants rank in the middle of the pack globally (~$422K median), well below sectors like Logistics, Multi-purpose Cash, and Shelter & NFI. The ◆ diamonds show that Ukraine’s health grants are significantly larger than the global norm, consistent with the fund’s overall pattern of larger, more consolidated grants.

5.1.6 Grant Size vs Number of Grants per Organisation

Each dot is one implementing organisation. The x-axis shows how many grants that organisation received over 2022–2025; the y-axis shows their median grant size. Organisations in the top-left corner are high-value, low-frequency recipients; those in the bottom-right are high-frequency implementers of smaller grants.

Interactive Dashboard: Explore this visualization interactively at shiny.baena.info/uhf-grant-size-2025 where you can filter by organization type, explore individual organizations, and hover for details.

Show code
# Prepare data
org_stats <- grants_cl %>%
  filter(AllocationYear >= 2022, AllocationYear <= 2025,
         !is.na(Budget), Budget > 0,
         !is.na(OrganizationName)) %>%
  group_by(Context, OrganizationName, OrganizationType) %>%
  summarise(
    n_grants    = n(),
    median_size = median(Budget),
    total_usd   = sum(Budget),
    pct_health  = mean(Cluster == "Health"),
    .groups     = "drop"
  ) %>%
  mutate(
    dot_group = case_when(
      pct_health >= 0.5               ~ "Health (≥50% health grants)",
      Context   == "Ukraine UHF"      ~ "Ukraine UHF",
      TRUE                            ~ "Global CBPF"
    )
  )

# Dynamic ceilings: y = max Ukraine median + 4M, x = max Ukraine n_grants + 3
ukr_max   <- org_stats %>% filter(Context == "Ukraine UHF") %>% pull(median_size) %>% max()
ukr_xmax  <- org_stats %>% filter(Context == "Ukraine UHF") %>% pull(n_grants)    %>% max()
y_ceiling <- ukr_max  + 4e6
x_ceiling <- ukr_xmax + 3

dot_colors <- c(
  "Ukraine UHF"                  = "#f4a261",
  "Global CBPF"                  = "#94d2bd",
  "Health (≥50% health grants)"  = "#e63946"
)

# Parametric ellipse for annotation (log-y space)
theta      <- seq(0, 2 * pi, length.out = 200)
ellipse_df <- data.frame(
  x = 9 + 4.2 * cos(theta),
  y = 10^(6.45 + 0.55 * sin(theta))
)

ggplot(org_stats,
       aes(x = n_grants, y = median_size,
           color = dot_group, size = total_usd)) +
  geom_path(data = ellipse_df, aes(x = x, y = y),
            inherit.aes = FALSE,
            color = "black", linewidth = 0.9, linetype = "solid") +
  annotate("text", x = 9, y = 10^6.9,
           label = "The UHF Exception",
           hjust = 0.5, vjust = 1, size = 4.5, fontface = "bold.italic", color = "black") +
  geom_point(alpha = 0.65, stroke = 0) +
  scale_y_log10(
    labels = scales::label_dollar(scale_cut = scales::cut_short_scale()),
    breaks = c(1e4, 5e4, 1e5, 5e5, 1e6, 5e6, 1e7)
  ) +
  scale_x_continuous(labels = scales::label_comma(),
                     breaks = c(1, 2, 3, 5, 7, 10, 15, 20)) +
  scale_color_manual(values = dot_colors, name = NULL,
                     guide  = guide_legend(override.aes = list(size = 4))) +
  scale_size_continuous(
    name   = "Total disbursed (2022–2025)",
    range  = c(2, 16),
    labels = scales::label_dollar(scale_cut = scales::cut_short_scale()),
    breaks = c(1e6, 5e6, 1e7, 3e7, 5e7),
    trans  = "sqrt"
  ) +
  coord_cartesian(xlim = c(NA, x_ceiling), ylim = c(NA, y_ceiling)) +
  labs(
    title    = "Grant Size vs Number of Grants per Organisation (2022–2025)",
    subtitle = "Each dot = one organisation · Y-axis log-scaled · Dot size = total disbursed · Red = majority health grants",
    x = "Number of grants received",
    y = "Median grant size (USD, log scale)",
    caption = "Source: CBPF ProjectSummary dataset, Feb 2026 extract. Axes clipped at max Ukraine values + buffer."
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "bottom",
    panel.grid.minor = element_blank()
  )

Show code
# Who are the high-value Ukraine outliers?
org_stats %>%
  filter(Context == "Ukraine UHF") %>%
  arrange(desc(total_usd)) %>%
  mutate(
    median_size = scales::dollar(median_size, scale_cut = scales::cut_short_scale(), accuracy = 0.01),
    total_usd   = scales::dollar(total_usd,   scale_cut = scales::cut_short_scale(), accuracy = 0.01),
    is_pure_health = pct_health == 1,
    across(
      c(OrganizationName, OrganizationType),
      ~ ifelse(is_pure_health,
               paste0("<span style='color:#e63946;font-weight:bold'>", .x, "</span>"),
               .x)
    ),
    n_grants    = ifelse(is_pure_health,
                         paste0("<span style='color:#e63946;font-weight:bold'>", n_grants, "</span>"),
                         as.character(n_grants)),
    median_size = ifelse(is_pure_health,
                         paste0("<span style='color:#e63946;font-weight:bold'>", median_size, "</span>"),
                         median_size),
    total_usd   = ifelse(is_pure_health,
                         paste0("<span style='color:#e63946;font-weight:bold'>", total_usd, "</span>"),
                         total_usd),
    pct_health  = ifelse(is_pure_health,
                         "<span style='color:#e63946;font-weight:bold'>100%</span>",
                         paste0(round(pct_health * 100), "%"))
  ) %>%
  select(
    Organisation      = OrganizationName,
    Type              = OrganizationType,
    `Grants received` = n_grants,
    `Median grant`    = median_size,
    `Total disbursed` = total_usd,
    `% Health grants` = pct_health
  ) %>%
  knitr::kable(caption = "Ukraine UHF organisations, 2022–2025 — ranked by total disbursed",
               escape  = FALSE)
Ukraine UHF organisations, 2022–2025 — ranked by total disbursed
Organisation Type Grants received Median grant Total disbursed % Health grants
Danish Refugee Council International NGO 10 $4.85M $47.10M 0%
ICF Caritas Ukraine National NGO 10 $3.96M $37.85M 0%
Agency for Technical Cooperation and Development International NGO 9 $5.00M $37.40M 0%
United Nations High Commissioner for Refugees UN Agency 7 $4.67M $33.31M 0%
Charitable Organization ‘Charitable Fund ’The Right to Protection’ National NGO 7 $4.99M $31.54M 0%
People in Need International NGO 7 $4.81M $31.11M 0%
International Organization for Migration UN Agency 6 $5.26M $26.90M 17%
Proliska National NGO 6 $4.71M $26.70M 0%
Charitable Organization ‘Charity Fund ’POSMISHKA UA’ National NGO 11 $2.37M $20.85M 0%
Norwegian Refugee Council International NGO 6 $2.45M $20.69M 0%
Food and Agriculture Organization of the United Nations UN Agency 6 $3.14M $17.97M 0%
Charity Foundation “NEW WAY” National NGO 10 $998.18K $17.26M 0%
Stichting ZOA International NGO 6 $2.71M $16.33M 0%
International Rescue Committee, Inc. International NGO 7 $1.46M $15.66M 57%
Zaporizhzhia Charitable Foundation ‘Unity’ for the Future’ National NGO 7 $2.46M $14.30M 0%
CHARITY ORGANIZATION «CHARITABLE FOUNDATION «АNGELS OF SALVATION» National NGO 4 $3.67M $13.36M 0%
Charitable organization “Charitable foundation “ROKADA” National NGO 6 $2.44M $12.74M 0%
Dorcas Aid International Transcarpathia National NGO 7 $2.01M $12.57M 0%
Estonian Refugee Council International NGO 8 $1.26M $10.93M 0%
Save the Children Fund International NGO 6 $1.88M $10.50M 0%
Help – Hilfe zur Selbsthilfe e.V. International NGO 4 $2.47M $9.67M 0%
INTERSOS International NGO 7 $1.29M $9.58M 57%
World Health Organization UN Agency 2 $4.73M $9.46M 100%
Global Emergency Relief, Recovery & Reconstruction International NGO 5 $2.19M $9.43M 0%
CORE Community Organized Relief Effort International NGO 4 $2.49M $8.84M 0%
Charitable Organization ‘Charity Fund Team4UA’ National NGO 2 $4.25M $8.50M 0%
MEDICAL TEAMS INTERNATIONAL International NGO 3 $2.50M $8.20M 100%
Street Child International NGO 5 $1.49M $8.18M 0%
CHARITABLE ORGANIZATION “CHARITY FOUNDATION “EAST-SOS” National NGO 4 $1.94M $8.10M 0%
CHARITABLE FOUNDATION ‘HUMANITARIAN AID AND DEVELOPMENT CENTRE’ National NGO 10 $997.64K $8.05M 0%
United Nations Children’s Fund UN Agency 2 $3.93M $7.86M 0%
CIVIC ORGANIZATION “THE TENTH OF APRIL” National NGO 3 $2.50M $7.79M 0%
World Food Programme UN Agency 3 $1.00M $7.47M 0%
Charitable Organization «RELIEF COORDINATION CENTRE» National NGO 4 $1.96M $7.42M 0%
UK-Med International NGO 3 $2.40M $6.90M 100%
United Nations Office for Project Services UN Agency 2 $3.38M $6.75M 100%
Polish Humanitarian Action International NGO 5 $1.40M $6.72M 0%
Hungarian Interchurch Aid International NGO 4 $1.49M $6.27M 0%
Charitable Organization Charitable Foundation “Development Center’ National NGO 6 $1.08M $6.23M 17%
Lumos Foundation International NGO 3 $1.78M $5.99M 0%
Nonviolent Peaceforce International International NGO 4 $1.49M $5.53M 0%
Samaritan’s Purse Ukraine International NGO 3 $1.70M $5.36M 0%
Triangle Generation Humanitaire International NGO 3 $2.11M $5.35M 0%
CHARITABLE ORGANIZATION ‘ALL-UKRAINIAN NETWORK OF PEOPLE LIVING WITH HIV/AIDS’ National NGO 4 $1.33M $5.12M 0%
Project Hope ‘The people to people health foundation’ Inc. International NGO 1 $4.96M $4.96M 0%
Kharkiv Regional Youth Non-Governmental Organisation ‘Enlightening Initiative’ National NGO 2 $2.32M $4.64M 0%
Terre des hommes - Aide a l’enfance dans le monde - Fondation International NGO 3 $1.69M $4.51M 0%
Caritas Czech Republic International NGO 3 $1.49M $4.49M 0%
AVSI Foundation International NGO 5 $998.41K $4.36M 0%
Corus International International NGO 3 $1.23M $4.15M 33%
Première Urgence Internationale International NGO 4 $800.00K $4.04M 75%
CHARITABLE ORGANIZATION “UKRAINIAN EDUCATION PLATFORM” National NGO 2 $1.62M $3.24M 0%
International Charitable Organization “East Europe Foundation” National NGO 2 $1.60M $3.21M 0%
FONDAZIONE TERRE DES HOMMES ITALIA - ONLUS International NGO 3 $1.01M $3.20M 0%
Deutsche Welthungerhilfe e.V. International NGO 3 $877.56K $2.97M 0%
NON-GOVERNMENTAL ORGANIZATION “GIRLS” National NGO 3 $983.74K $2.93M 0%
Medair International NGO 2 $1.38M $2.75M 0%
The International Charitable Foundation “Alliance for Public Health” National NGO 2 $1.35M $2.70M 100%
Charitable Organization ‘International Charitable Foundation ’Friends’ Hands’ National NGO 3 $967.47K $2.69M 0%
Adventist Development and Relief Agency – Ukraine National NGO 1 $2.69M $2.69M 0%
United Nations Population Fund UN Agency 2 $1.31M $2.61M 0%
Volontariato Internazionale per lo Sviluppo International NGO 2 $1.25M $2.50M 0%
Cesvi Fondazione - ETS International NGO 1 $2.50M $2.50M 0%
Fondazione We World - GVC Onlus International NGO 3 $1.00M $2.50M 0%
Ukrainian Deminers Association National NGO 4 $594.00K $2.48M 0%
Folkekirkens Nødhjælp International NGO 2 $1.20M $2.40M 0%
CARE Deutschland e. V. International NGO 1 $2.39M $2.39M 0%
Non-Governmental Organization Resource Center National NGO 4 $661.46K $2.36M 0%
HIAS Inc. International NGO 1 $2.20M $2.20M 0%
Solidarités International International NGO 2 $1.10M $2.20M 0%
NEW DAWN National NGO 3 $843.74K $2.06M 0%
CHARITABLE ORGANIZATION ‘CHARITY FUND ’GOODWILL’ National NGO 2 $1.00M $2.00M 0%
Medicos del Mundo International NGO 2 $950.00K $1.90M 100%
arche noVa (Representative office of arche noVa in Ukraine) International NGO 1 $1.88M $1.88M 0%
Foundazione di Religione “Opera San Francesco Saverio” - Collegio Universitario Aspiranti Medici Missionari - C.U.A.M.M. International NGO 3 $495.50K $1.72M 100%
humedica e.V. International NGO 2 $762.85K $1.53M 100%
Federation Handicap International International NGO 1 $1.50M $1.50M 0%
ChildFund Deutschland e. V. International NGO 2 $711.70K $1.42M 0%
MTU Mondo International NGO 2 $585.16K $1.17M 0%
Stichting War Child Alliance International NGO 1 $1.12M $1.12M 0%
Internationale Gesellschaft für Menschenrechte International NGO 2 $526.65K $1.05M 0%
CHARITY ORGANISATION “CHARITY FUND “ALPS RESILIENCE UKRAINE” International NGO 1 $999.89K $999.89K 0%
The Alliance for International Medical Action International NGO 1 $700.00K $700.00K 100%
Cooperative Housing Foundation International NGO 1 $699.41K $699.41K 0%
Action Contre la Faim International NGO 1 $697.06K $697.06K 0%
Charitable Organization “Light Of Hope” National NGO 1 $693.12K $693.12K 100%
Spring of Hope. Ukraine National NGO 1 $644.73K $644.73K 0%
International Children’s Fund ‘Mira’ National NGO 2 $321.59K $643.18K 0%
Bureau of gender strategy and budgeting National NGO 1 $615.78K $615.78K 0%
TSE - NASHA SPRAVA! National NGO 2 $249.94K $499.88K 0%
NGO Divergent Woman National NGO 2 $244.00K $487.99K 0%
Podolian Agency for Regional Development National NGO 1 $399.68K $399.68K 0%
Integration Center National NGO 1 $249.99K $249.99K 0%
Charitable Organization “Charitable Foundation ‘For the Future of Ukraine’ National NGO 1 $249.98K $249.98K 0%
Public Organization Common Cause for People National NGO 1 $249.98K $249.98K 0%
LAMPA National NGO 1 $249.97K $249.97K 0%
Public Organization YES National NGO 1 $249.58K $249.58K 0%
Charitable Organization “Charitable Foundation “Mission Kharkiv” National NGO 1 $248.35K $248.35K 100%
NGO “Airlight” National NGO 1 $238.19K $238.19K 100%
Charity Fund “VOLUNTEER MOVEMENT OF BUKOVYNA” National NGO 1 $237.55K $237.55K 0%

6 Partners per Allocation

Each CBPF allocation is a discrete funding round (e.g. “1st Standard Allocation 2023 — Ukraine”). The number of implementing partners selected per round is a direct signal of concentration: a fund that channels money through 10 partners per allocation is far more concentrated than one that routes it through 40. Ukraine’s profile as a high-intensity conflict fund, combined with its relatively small NNGO ecosystem, raises the question of whether it selects fewer partners than comparable funds globally.

6.1 Distribution: Ukraine vs Global

Show code
# Number of distinct partners per allocation round
# Use AllocationTypeId (e.g. "2022 3rd Reserve Allocation") as the round-level key.
# AllocationSourceID only takes 2 values (Standard/Reserve) and would conflate rounds.
partners_per_alloc <- project_summary %>%
  filter(AllocationYear >= 2014,
         !is.na(AllocationTypeId),
         !is.na(OrganizationName)) %>%
  group_by(PooledFundName, AllocationTypeId, AllocationType,
           AllocationSourceName, AllocationYear) %>%
  summarise(
    n_partners   = n_distinct(OrganizationName),
    total_budget = sum(Budget, na.rm = TRUE),
    n_projects   = n(),
    .groups = "drop"
  ) %>%
  mutate(Context = if_else(PooledFundName == "Ukraine", "Ukraine UHF", "Global CBPF"))

# Summary stats for callout
ppa_summary <- partners_per_alloc %>%
  group_by(Context) %>%
  summarise(
    n_allocs  = n(),
    med       = median(n_partners),
    q1        = quantile(n_partners, 0.25),
    q3        = quantile(n_partners, 0.75),
    mean_n    = round(mean(n_partners), 1),
    .groups   = "drop"
  )
Show code
ctx_colors_ppa <- c("Ukraine UHF" = "#005f73", "Global CBPF" = "#94d2bd")

med_labels <- partners_per_alloc %>%
  group_by(Context) %>%
  summarise(med = median(n_partners), .groups = "drop")

ggplot(partners_per_alloc,
       aes(x = n_partners, fill = Context, color = Context)) +
  geom_density(alpha = 0.35, linewidth = 0.9, adjust = 0.9) +
  geom_vline(data = med_labels,
             aes(xintercept = med, color = Context),
             linetype = "dashed", linewidth = 1.1) +
  geom_label(data = med_labels,
             aes(x = med, y = Inf,
                 label = paste0("Median: ", round(med), " partners"),
                 color = Context),
             vjust = 1.3, hjust = -0.07, size = 3.5, fontface = "bold",
             fill = "white", label.size = 0.3, show.legend = FALSE) +
  scale_fill_manual(values  = ctx_colors_ppa, name = NULL) +
  scale_color_manual(values = ctx_colors_ppa, name = NULL) +
  scale_x_continuous(breaks = c(5, 10, 20, 30, 40, 50, 60, 80, 100)) +
  labs(
    title    = "Partners per Allocation Round: Global CBPF vs Ukraine UHF (2014–2025)",
    subtitle = "Each observation is one allocation round; dashed lines mark medians",
    x = "Number of distinct implementing partners",
    y = "Density",
    caption = "Source: CBPF ProjectSummary dataset, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "top",
    legend.text   = element_text(size = 11, face = "bold"),
    panel.grid.minor = element_blank()
  )

Ukraine allocations reach 11.5 partners at the median vs 7 globally — Ukraine rounds involve roughly 1.6× more partners per allocation. Combined with the larger median grant size, this points not to a narrower partner base but to a bigger operational footprint per round — a reflection of the scale of needs and of the UHF’s ability to mobilise a wide partner pool.

Method note: each observation is one discrete allocation round identified by AllocationTypeId (e.g. “2022 3rd Reserve Allocation”, “2024 1st Standard Allocation”).

6.2 Partners per Allocation: Trend Over Time (Ukraine)

Ukraine’s partner count per allocation has evolved markedly since the full-scale invasion in 2022.

Show code
# Yearly median and IQR for Ukraine
ukr_trend <- partners_per_alloc %>%
  filter(Context == "Ukraine UHF") %>%
  group_by(AllocationYear) %>%
  summarise(
    med    = median(n_partners),
    q1     = quantile(n_partners, 0.25),
    q3     = quantile(n_partners, 0.75),
    n_obs  = n(),
    .groups = "drop"
  )

# Global yearly median for comparison
glob_trend <- partners_per_alloc %>%
  filter(Context == "Global CBPF") %>%
  group_by(AllocationYear) %>%
  summarise(
    med   = median(n_partners),
    q1    = quantile(n_partners, 0.25),
    q3    = quantile(n_partners, 0.75),
    .groups = "drop"
  )

ggplot() +
  # Global ribbon + line
  geom_ribbon(data = glob_trend,
              aes(x = AllocationYear, ymin = q1, ymax = q3),
              fill = "#94d2bd", alpha = 0.25) +
  geom_line(data = glob_trend,
            aes(x = AllocationYear, y = med, color = "Global CBPF median"),
            linewidth = 0.9, linetype = "dotted") +
  # Ukraine ribbon + line
  geom_ribbon(data = ukr_trend,
              aes(x = AllocationYear, ymin = q1, ymax = q3),
              fill = "#005f73", alpha = 0.20) +
  geom_line(data = ukr_trend,
            aes(x = AllocationYear, y = med, color = "Ukraine UHF median"),
            linewidth = 1.2) +
  geom_point(data = ukr_trend,
             aes(x = AllocationYear, y = med, color = "Ukraine UHF median"),
             size = 3) +
  geom_text(data = ukr_trend,
            aes(x = AllocationYear, y = med,
                label = round(med)),
            vjust = -0.9, size = 3.2, fontface = "bold", color = "#005f73") +
  annotate("rect", xmin = 2021.5, xmax = Inf, ymin = -Inf, ymax = Inf,
           fill = "#e63946", alpha = 0.04) +
  annotate("text", x = 2022, y = Inf, label = "Full-scale\ninvasion",
           vjust = 1.4, hjust = 0, size = 3, color = "#e63946", fontface = "italic") +
  scale_color_manual(
    values = c("Ukraine UHF median" = "#005f73", "Global CBPF median" = "#2ca58d"),
    name   = NULL
  ) +
  scale_x_continuous(breaks = 2014:2025) +
  labs(
    title    = "Median Partners per Allocation Round by Year",
    subtitle = "Shaded bands = IQR; dotted line = global median; solid = Ukraine",
    x = "Allocation year", y = "Partners per allocation (median)",
    caption = "Source: CBPF ProjectSummary dataset, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, color = "#005f73"),
    plot.subtitle = element_text(size = 11, color = "#555555"),
    legend.position = "top",
    legend.text   = element_text(size = 11),
    panel.grid.minor = element_blank(),
    axis.text.x   = element_text(angle = 45, hjust = 1)
  )


7 Concentration, Scale, and the Core-20

If UHF involves more partners per round than peer funds and grants larger amounts per grant, a natural question follows: is the resulting portfolio more concentrated than peer CBPFs, and who sits at the top? This chapter answers both questions quantitatively. The headline is counterintuitive: UHF is not more concentrated than comparable CBPFs — it is simply larger. The appearance of concentration is produced by the interaction of envelope size with the $5M per-grant cap, and by a stable 20-organisation core that has recurred in every allocation round since 2022.

7.1 Ukraine vs Peer CBPFs: Concentration Benchmark

Peer set: the seven other largest CBPFs by 2022–2024 envelope (Afghanistan, Syria Cross-border, Sudan, Yemen, Ethiopia, Somalia, oPt). Two natural classes emerge — mega-concentrated access funds (Sudan, Syria XB, with only 3–4 partners per year and HHI > 0.3) and broad partner funds (Afghanistan, Yemen, Ethiopia, Somalia, oPt, Ukraine). Ukraine sits in the second class.

Show code
peer_funds <- c("Ukraine", "Afghanistan", "Syria Cross border",
                "Sudan", "Yemen", "Ethiopia", "Somalia", "oPt")

conc_agg <- project_summary %>%
  filter(AllocationYear %in% 2022:2024,
         PooledFundName %in% peer_funds,
         !is.na(Budget), Budget > 0,
         !is.na(OrganizationName)) %>%
  group_by(PooledFundName, OrganizationName) %>%
  summarise(amt = sum(Budget), .groups = "drop") %>%
  group_by(PooledFundName) %>%
  arrange(desc(amt), .by_group = TRUE) %>%
  mutate(share = amt / sum(amt)) %>%
  summarise(
    n_partners = n(),
    envelope   = sum(amt),
    HHI        = sum(share^2),
    top5       = sum(share[1:min(5, n())]),
    top10      = sum(share[1:min(10, n())]),
    .groups = "drop"
  ) %>%
  mutate(
    Class = if_else(n_partners <= 5,
                    "Access-restricted (UN-dominant)",
                    "Broad partner fund"),
    is_ukraine = PooledFundName == "Ukraine"
  )

# Two aligned horizontal-bar panels, funds sorted by HHI.
# Ukraine highlighted in red. The visual punchline: mid-row on HHI,
# near-top row on envelope -> "same shape, bigger envelope."
conc_plot <- conc_agg %>%
  mutate(Fund = forcats::fct_reorder(PooledFundName, HHI))

bar_fill <- c(`TRUE` = "#c9184a", `FALSE` = "#8aa9b0")

p_hhi <- ggplot(conc_plot, aes(x = HHI, y = Fund, fill = is_ukraine)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = sprintf("%.3f", HHI)),
            hjust = -0.15, size = 3.5, colour = "#333333") +
  scale_fill_manual(values = bar_fill, guide = "none") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.18))) +
  labs(title    = "Concentration (HHI)",
       subtitle = "Lower = more evenly spread across partners",
       x = NULL, y = NULL) +
  theme_minimal(base_size = 12) +
  theme(plot.title         = element_text(face = "bold", colour = "#005f73"),
        plot.subtitle      = element_text(size = 10, colour = "#555555"),
        panel.grid.major.y = element_blank(),
        panel.grid.minor   = element_blank())

p_env <- ggplot(conc_plot, aes(x = envelope / 1e6, y = Fund, fill = is_ukraine)) +
  geom_col(width = 0.7) +
  geom_text(aes(label = scales::dollar(envelope / 1e6, suffix = "M", accuracy = 1)),
            hjust = -0.15, size = 3.5, colour = "#333333") +
  scale_fill_manual(values = bar_fill, guide = "none") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.22))) +
  labs(title    = "Envelope 2022–2024",
       subtitle = "Absolute USD disbursed",
       x = NULL, y = NULL) +
  theme_minimal(base_size = 12) +
  theme(plot.title         = element_text(face = "bold", colour = "#005f73"),
        plot.subtitle      = element_text(size = 10, colour = "#555555"),
        axis.text.y        = element_blank(),
        panel.grid.major.y = element_blank(),
        panel.grid.minor   = element_blank())

(p_hhi | p_env) +
  plot_annotation(
    title    = "Ukraine sits mid-pack on concentration — but tops the envelope ranking",
    subtitle = "Same fund order in both panels (sorted by HHI). Ukraine highlighted in red.",
    caption  = "Source: CBPF ProjectSummary dataset, Feb 2026 extract",
    theme = theme(plot.title    = element_text(face = "bold", size = 14, colour = "#005f73"),
                  plot.subtitle = element_text(size = 11, colour = "#555555"))
  )

Show code
conc_agg %>%
  mutate(
    Envelope      = scales::dollar(envelope / 1e6, suffix = "M", accuracy = 0.1),
    HHI           = sprintf("%.3f", HHI),
    `Top-5 share` = scales::percent(top5, accuracy = 0.1),
    `Top-10 share` = scales::percent(top10, accuracy = 0.1)
  ) %>%
  arrange(desc(envelope)) %>%
  select(Fund = PooledFundName, Envelope, Partners = n_partners,
         HHI, `Top-5 share`, `Top-10 share`) %>%
  knitr::kable(caption = "CBPF concentration metrics, 2022–2024 (aggregated across the three years).")
CBPF concentration metrics, 2022–2024 (aggregated across the three years).
Fund Envelope Partners HHI Top-5 share Top-10 share
Ukraine $540.7M 94 0.029 28.3% 46.0%
Afghanistan $472.8M 107 0.052 42.3% 52.8%
Syria Cross border $350.2M 4 0.378 100.0% 100.0%
Sudan $327.5M 4 0.818 100.0% 100.0%
Yemen $201.6M 73 0.026 23.6% 38.3%
Ethiopia $187.8M 65 0.037 32.1% 51.2%
Somalia $161.0M 113 0.014 14.3% 24.8%
oPt $158.5M 54 0.039 31.5% 52.5%

Reading the map. Among broad partner funds, Ukraine is unremarkable on shape — lower HHI than Afghanistan or Ethiopia, higher than Somalia or Yemen, mid-pack on top-5 (28%) and top-10 (46%) share. What makes it look concentrated is the envelope size: at $541M over three years UHF is twice Yemen’s volume on the same HHI. Moderate concentration × large envelope = large absolute disbursements to the top partners.

Show code
conc_year <- project_summary %>%
  filter(PooledFundName == "Ukraine",
         AllocationYear %in% 2022:2025,
         !is.na(Budget), Budget > 0,
         !is.na(OrganizationName)) %>%
  group_by(AllocationYear, OrganizationName) %>%
  summarise(amt = sum(Budget), .groups = "drop") %>%
  group_by(AllocationYear) %>%
  arrange(desc(amt), .by_group = TRUE) %>%
  mutate(share = amt / sum(amt)) %>%
  summarise(
    HHI   = sum(share^2),
    top5  = sum(share[1:min(5, n())]),
    top10 = sum(share[1:min(10, n())]),
    .groups = "drop"
  ) %>%
  tidyr::pivot_longer(cols = c(top5, top10), names_to = "metric", values_to = "value") %>%
  mutate(metric = recode(metric, top5 = "Top-5 share", top10 = "Top-10 share"))

ggplot(conc_year,
       aes(x = AllocationYear, y = value, colour = metric, group = metric)) +
  geom_line(linewidth = 1.3) +
  geom_point(size = 3.5) +
  geom_text(aes(label = scales::percent(value, accuracy = 1)),
            vjust = -1.1, size = 3.2, fontface = "bold", show.legend = FALSE) +
  scale_colour_manual(values = c("Top-5 share" = "#005f73",
                                 "Top-10 share" = "#94d2bd"),
                      name = NULL) +
  scale_y_continuous(labels = scales::percent_format(),
                     limits = c(0, 0.75),
                     expand = expansion(mult = c(0, 0.05))) +
  scale_x_continuous(breaks = 2022:2025) +
  labs(
    title = "UHF is de-concentrating: top-5 and top-10 partner shares over time",
    subtitle = "Ukraine UHF, 2022–2025 — partner shares calculated per year",
    x = "Allocation year", y = "Share of annual envelope",
    caption = "Source: CBPF ProjectSummary dataset, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, colour = "#005f73"),
    plot.subtitle = element_text(size = 11, colour = "#555555"),
    legend.position = "top",
    panel.grid.minor = element_blank()
  )

The trajectory is toward more partners and smaller top-tier shares, not fewer. Top-5 share fell from 40% in 2022 to 28% in 2024; top-10 from 62% to 45%. Consistent with the localisation push documented in the 2024 Annual Report (NNGO share rising from 30% in 2023 to ~60% in 2024) and with the UN Agency exit (see below).

7.2 Why UHF Grants Are Bigger: An Arithmetic Identity

Because Ukraine’s partner count is similar to Afghanistan or Yemen but its envelope is roughly 2× larger, the average partner take mechanically has to be larger. This is not a policy choice — it’s arithmetic — and it explains most of the “larger grants” signal in Chapter 5.

Show code
peer_grants <- project_summary %>%
  filter(AllocationYear %in% 2022:2024,
         PooledFundName %in% peer_funds,
         !is.na(Budget), Budget > 0)

ref_lines <- data.frame(
  yintercept = c(5e6, 8e6),
  label      = c("Standard cap $5M", "Exceptional cap $8M"),
  colour     = c("#e63946", "#e76f51")
)

ggplot(peer_grants %>% filter(PooledFundName %in% c("Ukraine","Afghanistan","Yemen","Ethiopia","oPt","Somalia")),
       aes(x = reorder(PooledFundName, Budget, FUN = median), y = Budget)) +
  geom_boxplot(aes(fill = PooledFundName == "Ukraine"),
               outlier.size = 0.9, outlier.alpha = 0.5, width = 0.55) +
  geom_hline(yintercept = 5e6, linetype = "dashed", colour = "#e63946", linewidth = 0.7) +
  annotate("text", x = 0.6, y = 5.3e6, label = "Standard cap $5M",
           colour = "#e63946", size = 3.2, hjust = 0, fontface = "italic") +
  scale_y_log10(labels = scales::label_dollar(scale_cut = scales::cut_short_scale()),
                breaks = c(5e4, 1e5, 5e5, 1e6, 2e6, 5e6, 1e7)) +
  scale_fill_manual(values = c(`TRUE` = "#005f73", `FALSE` = "#94d2bd"),
                    guide = "none") +
  coord_flip() +
  labs(
    title = "Grant-size distribution: Ukraine vs broad peer CBPFs (2022–2024)",
    subtitle = "Log-scale. Ukraine's 90th percentile sits exactly at the $5M standard cap — a ceiling-bound distribution no peer fund exhibits.",
    x = NULL, y = "Grant budget (USD, log scale)",
    caption = "Source: CBPF ProjectSummary dataset, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, colour = "#005f73"),
    plot.subtitle = element_text(size = 11, colour = "#555555"),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank()
  )

Show code
per_partner <- conc_agg %>%
  filter(Class == "Broad partner fund") %>%
  mutate(
    mean_per_partner = envelope / n_partners,
    Envelope = scales::dollar(envelope / 1e6, suffix = "M", accuracy = 0.1),
    `Mean \\$ per partner (3y)` =
      scales::dollar(mean_per_partner / 1e6, suffix = "M", accuracy = 0.01)
  ) %>%
  arrange(desc(mean_per_partner)) %>%
  select(Fund = PooledFundName, Envelope, Partners = n_partners,
         `Mean \\$ per partner (3y)`)

knitr::kable(per_partner,
             caption = "Mean 3-year envelope per partner in broad-partner CBPFs (2022–2024). UHF's arithmetic average is roughly 2× the next-largest fund.")
Mean 3-year envelope per partner in broad-partner CBPFs (2022–2024). UHF’s arithmetic average is roughly 2× the next-largest fund.
Fund Envelope Partners Mean $ per partner (3y)
Ukraine $540.7M 94 $5.75M
Afghanistan $472.8M 107 $4.42M
oPt $158.5M 54 $2.94M
Ethiopia $187.8M 65 $2.89M
Yemen $201.6M 73 $2.76M
Somalia $161.0M 113 $1.42M

The P90 signature is unique to UHF. Ukraine is the only CBPF where the 90th percentile grant size sits exactly at the $5M standard cap — producing a flat-topped distribution. Peer funds show long right tails with a few mega-grants pulling the top; Ukraine shows a ceiling. The operational reading is that the UHF routinely writes max-size grants to a large number of partners simultaneously.

7.3 The Core-20: Who Captures the Majority

Filtering to organisations that received grants in every UHF allocation round 2022–2024 yields a core of 20 partners. Together they absorbed 55.3% of the $541M three-year envelope.

7.4 The Four Types, Operationalised

The article’s framing points to a key insight: partners cluster into four risk-bearing profiles based on their institutional scale relative to UHF engagement, and their operational history relative to the 2022 invasion. To move beyond qualitative description, each type is operationalised with a rule-based classification so that every partner (not just the Core-20) can be scored.

Partner typology: rule-based classification covering all 94 Ukraine partners
Type Operational Rule Risk Signature
D — Hyperscalers UN Agency, or pre-war global revenue ≥ $50M UHF volume is absorbable; institutional scale ≫ grant scale
C — Localisation cohort National NGO with first CBPF grant before 2022 Pre-war Ukrainian track record; absorbing transformation
B — Small-base pre-war Pre-2022 founding (revenue < $20M), or pre-2022 INGO with first CBPF grant ≥ 2022 UHF scale ≈ pre-war annual revenue; outside CBPF history exists
A — Born of the war Founded 2022 or later; entity co-emergent with UHF No independent baseline; UHF engagement is institutional track record

For the Core-20, classification is hardcoded using the revenue data in the rev_usd tibble and confirmed founding dates. For the remaining 74 partners in the 94-partner universe, the rules are applied automatically: Type A captures only organizations with confirmed founding year ≥ 2022; others classify via OrganizationType and founding/first-CBPF-year.

Show code
ukr22_24 <- project_summary %>%
  filter(PooledFundName == "Ukraine",
         AllocationYear %in% 2022:2024,
         !is.na(Budget), Budget > 0,
         !is.na(OrganizationName))

ukr25_partner <- project_summary %>%
  filter(PooledFundName == "Ukraine", AllocationYear == 2025,
         !is.na(Budget), Budget > 0, !is.na(OrganizationName)) %>%
  group_by(OrganizationName) %>%
  summarise(n25 = n(), amt25 = sum(Budget, na.rm = TRUE), .groups = "drop")

core20 <- ukr22_24 %>%
  group_by(OrganizationName, OrganizationType) %>%
  summarise(
    n_grants   = n(),
    n_years    = n_distinct(AllocationYear),
    total      = sum(Budget, na.rm = TRUE),
    mean_grant = mean(Budget, na.rm = TRUE),
    max_grant  = max(Budget, na.rm = TRUE),
    .groups    = "drop"
  ) %>%
  filter(n_years == 3) %>%
  left_join(ukr25_partner, by = "OrganizationName") %>%
  arrange(desc(total)) %>%
  mutate(
    Type = case_when(
      OrganizationType == "International NGO" ~ "INGO",
      OrganizationType == "National NGO"      ~ "NNGO",
      OrganizationType == "UN Agency"         ~ "UN",
      TRUE                                    ~ "Other"
    ),
    `Total 22–24`    = scales::dollar(total / 1e6, suffix = "M", accuracy = 0.1),
    `Mean grant`     = scales::dollar(mean_grant / 1e6, suffix = "M", accuracy = 0.01),
    `Max grant`      = scales::dollar(max_grant  / 1e6, suffix = "M", accuracy = 0.01),
    `2025 total`     = ifelse(is.na(amt25), "—",
                              scales::dollar(amt25 / 1e6, suffix = "M", accuracy = 0.01))
  ) %>%
  select(Organisation = OrganizationName, Type,
         Grants = n_grants, `Total 22–24`, `Mean grant`, `Max grant`, `2025 total`)

knitr::kable(core20,
             caption = "Core-20: organisations active in every UHF allocation round 2022–2024, ranked by cumulative disbursement. 2025 column shows whether the partner continued into the current cycle.",
             align  = c("l","l","c","r","r","r","r"))
Core-20: organisations active in every UHF allocation round 2022–2024, ranked by cumulative disbursement. 2025 column shows whether the partner continued into the current cycle.
Organisation Type Grants Total 22–24 Mean grant Max grant 2025 total
Agency for Technical Cooperation and Development INGO 8 $36.4M $4.55M $6.30M $1.00M
United Nations High Commissioner for Refugees UN 7 $33.3M $4.76M $9.43M
Danish Refugee Council INGO 8 $30.1M $3.76M $5.00M $17.00M
People in Need INGO 6 $26.3M $4.38M $6.14M $4.81M
ICF Caritas Ukraine NNGO 7 $23.8M $3.40M $6.00M $14.06M
Norwegian Refugee Council INGO 5 $18.3M $3.66M $7.50M $2.40M
Proliska NNGO 4 $17.5M $4.38M $5.23M $9.17M
Charity Foundation “NEW WAY” NNGO 8 $12.6M $1.58M $3.42M $4.64M
Charitable Organization ‘Charity Fund ’POSMISHKA UA’ NNGO 7 $12.4M $1.76M $2.50M $8.50M
International Rescue Committee, Inc. INGO 5 $11.4M $2.28M $4.84M $4.25M
Stichting ZOA INGO 5 $11.4M $2.28M $4.00M $4.94M
Dorcas Aid International Transcarpathia NNGO 6 $10.6M $1.76M $2.50M $2.01M
Estonian Refugee Council INGO 7 $9.4M $1.35M $2.50M $1.50M
CORE Community Organized Relief Effort INGO 4 $8.8M $2.21M $3.16M
Zaporizhzhia Charitable Foundation ‘Unity’ for the Future’ NNGO 4 $8.3M $2.09M $2.50M $5.96M
Save the Children Fund INGO 5 $8.2M $1.64M $2.50M $2.29M
INTERSOS INGO 6 $7.6M $1.26M $2.31M $2.00M
CHARITABLE FOUNDATION ‘HUMANITARIAN AID AND DEVELOPMENT CENTRE’ NNGO 8 $6.1M $0.76M $1.00M $2.00M
AVSI Foundation INGO 4 $3.4M $0.84M $1.00M $1.00M
Nonviolent Peaceforce International INGO 3 $3.3M $1.11M $1.97M $2.21M

7.4.1 Partner Distribution by Type

The four-type classification reveals how the 94 distinct Ukraine partners distribute across risk profiles:

Show code
typology_table <- typology_summary %>%
  arrange(desc(total_usd)) %>%
  transmute(
    Type = Type_Label,
    `# Partners` = n_partners,
    `# Grants` = n_grants,
    `Total funding` = scales::dollar(total_usd / 1e6, suffix = "M", accuracy = 1),
    `% of envelope` = sprintf("%.1f%%", pct_of_total),
    `Avg partner` = scales::dollar(mean_partner_usd / 1e6, suffix = "M", accuracy = 0.01),
    `Avg grant` = scales::dollar(mean_grant / 1e6, suffix = "M", accuracy = 0.01)
  )

knitr::kable(typology_table,
             caption = "UHF partners 2022–2025 by typology. Type D (hyperscalers) dominate funding with minimal partner count; Type C (localisation cohort) balances volume with partner diversity; Types B and A remain numerically significant but fiscally secondary.",
             align = c("l", "c", "c", "r", "r", "r", "r"))
UHF partners 2022–2025 by typology. Type D (hyperscalers) dominate funding with minimal partner count; Type C (localisation cohort) balances volume with partner diversity; Types B and A remain numerically significant but fiscally secondary.
Type # Partners # Grants Total funding % of envelope Avg partner Avg grant
Small-base pre-war 84 260 $425M 57.3% $424.80M $1.63M
Hyperscaler 14 72 $253M 34.0% $252.54M $3.51M
Localisation cohort 2 16 $65M 8.7% $64.55M $4.03M

Hyperscalers (Type D) dominate by funding but are sparse by partner count. The localisation cohort (Type C) — pre-2022 Ukrainian NGOs — combine meaningful volume with deep field presence. Types B (small-base pre-war) and A (born of the war) are numerically larger but absorb far less funding, reflecting deliberate fund architecture: concentrate large grants in absorptive-capacity partners; use smaller allocations to maintain ecosystem diversity and test new capabilities.

Show code
# Data prep for dual visualization
typ_budget <- typology_summary %>%
  arrange(Type) %>%
  mutate(Type_Label = factor(Type_Label, 
                              levels = c("Hyperscaler", "Localisation cohort", 
                                        "Small-base pre-war", "Post-invasion entrant")))

# Left: Budget by type
p_budget <- ggplot(typ_budget, aes(x = Type_Label, y = total_usd / 1e6, fill = Type)) +
  geom_col(alpha = 0.85, width = 0.6) +
  geom_text(aes(label = sprintf("$%.0fM\n(%.1f%%)", total_usd/1e6, pct_of_total)),
            vjust = -0.3, size = 3.5, fontface = "bold") +
  scale_fill_manual(values = c("D" = "#005f73", "C" = "#0a9396", 
                                "B" = "#ee9b00", "A" = "#ca6702"),
                    guide = "none") +
  scale_y_continuous(labels = scales::dollar_format(suffix = "M", accuracy = 1),
                     expand = expansion(mult = c(0, 0.2))) +
  labs(x = NULL, y = "Total funding 2022–2025",
       title = "Budget by Partner Type") +
  theme_minimal(base_size = 11) +
  theme(
    plot.title = element_text(face = "bold", size = 12, color = "#005f73"),
    axis.text.x = element_text(angle = 20, hjust = 1, size = 10),
    panel.grid.major.x = element_blank()
  )

# Right: Partner count by type
p_partners <- ggplot(typ_budget, aes(x = Type_Label, y = n_partners, fill = Type)) +
  geom_col(alpha = 0.85, width = 0.6) +
  geom_text(aes(label = n_partners),
            vjust = -0.3, size = 3.5, fontface = "bold") +
  scale_fill_manual(values = c("D" = "#005f73", "C" = "#0a9396", 
                                "B" = "#ee9b00", "A" = "#ca6702"),
                    guide = "none") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
  labs(x = NULL, y = "Number of partners",
       title = "Partner Count by Type") +
  theme_minimal(base_size = 11) +
  theme(
    plot.title = element_text(face = "bold", size = 12, color = "#005f73"),
    axis.text.x = element_text(angle = 20, hjust = 1, size = 10),
    panel.grid.major.x = element_blank()
  )

p_budget + p_partners

The inverse relationship: Type D (hyperscalers) = smallest number, largest budget. Type A (born of the war) = rarest, smallest budgets. Type B (small-base pre-war) = larger number with moderate budgets. This pattern reflects deliberate fund architecture: concentrate delivery in proven-capacity partners, maintain ecosystem diversity via Type B and C, and use Type A selectively to pilot emerging capabilities with robust monitoring.

Seven partners are “cap-binders”: ACTED, UNHCR, DRC, People in Need, ICF Caritas Ukraine, NRC and Proliska all average grants of $3.4M–$4.8M — every grant at or near the $5M ceiling. Six of them received at least one exceptional-cap grant ≥ $6M (UNHCR $9.4M, NRC $7.5M, ACTED $6.3M, PIN $6.1M, Caritas $6.0M, Proliska $5.2M). This is where the “repetition at large scale” pattern concentrates — not across the fund, but in a ~7-organisation inner tier.

The UN exit is visible at partner level. UNHCR was the second-largest UHF partner 2022–2024 ($33.3M across 7 grants) and received $0 in 2025. CORE Community Organized Relief Effort similarly drops out. Their volume did not leave Ukraine — it was absorbed primarily by DRC ($17.0M in 2025), ICF Caritas Ukraine ($14.1M in 2025) and a broadened NNGO tier.

7.5 Dependency Ratio: When UHF Dwarfs the Partner

For the 12 core-20 members with publicly available 2021 revenue, the dependency ratio (UHF 2022–2024 volume / 2021 global organisational revenue) distinguishes absorbable UHF engagement from structural dependency.

Show code
# Revenue data compiled from publicly available sources (audited accounts
# and annual reports). Unverifiable cases excluded from this table.
rev_usd <- tibble::tribble(
  ~Organisation,                                     ~rev_2021_usd_m,  ~rev_note,
  "Agency for Technical Cooperation and Development", 463, "€393.7M (2021)",
  "United Nations High Commissioner for Refugees",   5150, "$5.15B (2021)",
  "Danish Refugee Council",                           495, "3.12B DKK (2021)",
  "People in Need",                                   133, "€113M (2021)",
  "Norwegian Refugee Council",                        660, "5.67B NOK (2021)",
  "International Rescue Committee, Inc.",            1160, "$1.16B (2021)",
  "Stichting ZOA",                                     96, "€82M (2021)",
  "Dorcas Aid International Transcarpathia",           28, "€24M parent (2021)",
  "Estonian Refugee Council",                         2.2, "€1.9M (2021)",
  "CORE Community Organized Relief Effort",           122, "$122.1M (2021)",
  "Save the Children Fund",                          2200, "$2.2B (2021)",
  "INTERSOS",                                         126, "€107M (2021)"
)

dep <- ukr22_24 %>%
  group_by(OrganizationName) %>%
  summarise(uhf_total = sum(Budget, na.rm = TRUE), .groups = "drop") %>%
  inner_join(rev_usd, by = c("OrganizationName" = "Organisation")) %>%
  mutate(
    uhf_m    = uhf_total / 1e6,
    ratio    = uhf_m / rev_2021_usd_m,
    Reading  = case_when(
      ratio <  0.1 ~ "Minor line",
      ratio <  0.5 ~ "Meaningful",
      ratio <  2   ~ "Structural dependency",
      TRUE         ~ "UHF dwarfs org"
    ),
    Reading = factor(Reading,
                     levels = c("Minor line","Meaningful",
                                "Structural dependency","UHF dwarfs org"))
  ) %>%
  arrange(desc(ratio))

dep_tbl <- dep %>%
  transmute(
    Organisation   = OrganizationName,
    `2021 revenue` = rev_note,
    `UHF 22–24`    = scales::dollar(uhf_m, suffix = "M", accuracy = 0.01),
    Ratio          = sprintf("%.2f×", ratio),
    Reading
  )

knitr::kable(dep_tbl,
             caption = "Dependency ratio for core-20 members with public 2021 revenue. Ratio = UHF 2022–2024 disbursement divided by the organisation's 2021 global revenue (USD-equivalent).",
             align  = c("l","l","r","r","l"))
Dependency ratio for core-20 members with public 2021 revenue. Ratio = UHF 2022–2024 disbursement divided by the organisation’s 2021 global revenue (USD-equivalent).
Organisation 2021 revenue UHF 22–24 Ratio Reading
Estonian Refugee Council €1.9M (2021) $9.43M 4.29× UHF dwarfs org
Dorcas Aid International Transcarpathia €24M parent (2021) $10.55M 0.38× Meaningful
People in Need €113M (2021) $26.30M 0.20× Meaningful
Stichting ZOA €82M (2021) $11.39M 0.12× Meaningful
Agency for Technical Cooperation and Development €393.7M (2021) $36.40M 0.08× Minor line
CORE Community Organized Relief Effort $122.1M (2021) $8.84M 0.07× Minor line
Danish Refugee Council 3.12B DKK (2021) $30.10M 0.06× Minor line
INTERSOS €107M (2021) $7.58M 0.06× Minor line
Norwegian Refugee Council 5.67B NOK (2021) $18.29M 0.03× Minor line
International Rescue Committee, Inc. $1.16B (2021) $11.42M 0.01× Minor line
United Nations High Commissioner for Refugees $5.15B (2021) $33.31M 0.01× Minor line
Save the Children Fund $2.2B (2021) $8.21M 0.00× Minor line
Show code
dep_plot <- dep %>%
  mutate(OrganizationName = forcats::fct_reorder(OrganizationName, ratio))

ggplot(dep_plot,
       aes(x = ratio, y = OrganizationName, colour = Reading)) +
  geom_vline(xintercept = 1, colour = "#e63946",
             linetype = "dashed", linewidth = 0.8) +
  geom_segment(aes(x = 0.001, xend = ratio, yend = OrganizationName),
               linewidth = 0.4, colour = "grey70") +
  geom_point(size = 4) +
  geom_text(aes(label = sprintf("%.2f×", ratio)),
            hjust = -0.25, size = 3.2, fontface = "bold", show.legend = FALSE) +
  scale_x_log10(
    breaks = c(0.01, 0.1, 0.5, 1, 4),
    labels = c("0.01×", "0.1×", "0.5×", "1×", "4×"),
    expand = expansion(mult = c(0.05, 0.35))
  ) +
  scale_colour_manual(values = c(
    "Minor line"             = "#94d2bd",
    "Meaningful"             = "#0a9396",
    "Structural dependency"  = "#ee9b00",
    "UHF dwarfs org"         = "#e63946"
  ), name = NULL) +
  labs(
    title = "UHF dependency ratio: UHF 2022–2024 volume ÷ 2021 global revenue",
    subtitle = "Log scale. Red dashed line = UHF equal to entire pre-war annual revenue.",
    x = "Dependency ratio (log scale)", y = NULL,
    caption = "Source: organisation audited accounts and annual reports, 2021. UHF volume from CBPF ProjectSummary, Feb 2026."
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title    = element_text(face = "bold", size = 13, colour = "#005f73"),
    plot.subtitle = element_text(size = 10, colour = "#555555"),
    legend.position = "top",
    panel.grid.minor = element_blank()
  )

Dependency ratio by core-20 partner (log-scaled). The y=1 line marks parity between UHF volume and pre-war annual revenue. Estonian Refugee Council sits more than 4× above parity — UHF volume exceeds its entire pre-war institutional scale.

Estonian Refugee Council is the sharpest visible case, but this is only due to its financial transparency: a domestic Estonian NGO with €1.9M 2021 revenue absorbing $9.4M of UHF funding over 2022–2024 — 4.3× its pre-war annual scale. Whatever governance, finance and compliance infrastructure existed in 2021 was sized for a €1.9M/year operation. UHF is not supplementing this organisation; UHF is its operational scale. Dorcas Aid International Transcarpathia is a parallel case at 0.38× against the €24M global parent, though the country-unit share is likely higher.

Transparency gap. Seven of the eight core-20 Ukrainian NNGOs (ICF Caritas Ukraine, Proliska, NEW WAY, POSMISHKA UA, Unity for the Future, HADC, and others appearing in adjacent cohorts) have no publicly available 2021 revenue figure. An independent capacity-to-volume assessment is therefore blocked on the denominator side. This is not a criticism — it reflects a legitimate gap in the public-disclosure practice of domestic NGOs operating in a war economy — but it should be flagged in any external review of UHF’s fiduciary exposure.


8 The Ideal UHF Candidate Profile

The preceding chapters characterised the UHF system-wide. This chapter flips the lens: given what we know about how the UHF allocates money, what does an ideal partner look like? Rather than inferring intent from eligibility documents, we read the profile off the disbursement history itself — i.e. the organisations that the UHF has repeatedly trusted with large, complex programmes. Two dimensions define the profile:

  1. Geographic coverage — the number of distinct oblasts an organisation operates in. A broad footprint signals the ability to deliver across the contact line and in liberated/recovery regions alike.
  2. Sector coverage — the number of distinct clusters an organisation engages in. Multi-sector capacity signals the ability to bundle protection, shelter, WASH, health or food security into one integrated programme.

Together these two axes define a simple 2×2 that partitions the partner universe into Specialists, Area generalists, Sector generalists, and Full-coverage partners. The ideal candidate sits in the upper-right quadrant.

8.1 Mapping the Partner Universe

The scatter plot below places every Ukraine UHF partner (2019–2025) on the two coverage axes. Bubble size is proportional to cumulative disbursed budget; colour reflects organisation type. Dashed lines mark the 75th-percentile thresholds that split the plane into four quadrants.

Show code
library(ggrepel)

# Label the biggest bubbles + interesting outliers
label_orgs <- org_cov %>%
  mutate(rank_bud = rank(-total_budget)) %>%
  filter(rank_bud <= 10 | (Quadrant == "Full-coverage" & rank_bud <= 20)) %>%
  mutate(lab = stringr::str_wrap(OrganizationName, 26))

pal_orgtype <- c("INGO" = "#0A9396", "NNGO" = "#EE9B00", "UN Agency" = "#005f73", "Other" = "#94d2bd")

ggplot(org_cov, aes(x = n_oblasts, y = n_clusters)) +
  # quadrant shading
  annotate("rect", xmin = q_oblasts, xmax = Inf, ymin = q_clusters, ymax = Inf,
           fill = "#0a9396", alpha = 0.07) +
  geom_hline(yintercept = q_clusters, linetype = "dashed", colour = "grey55") +
  geom_vline(xintercept = q_oblasts,  linetype = "dashed", colour = "grey55") +
  geom_point(aes(size = total_budget / 1e6, colour = OrgType),
             alpha = 0.75, stroke = 0.3) +
  geom_text_repel(
    data = label_orgs,
    aes(label = lab, colour = OrgType),
    size = 3.1, lineheight = 0.9,
    max.overlaps = Inf, seed = 42,
    box.padding = 0.35, point.padding = 0.2,
    segment.size = 0.3, segment.colour = "grey60",
    show.legend = FALSE
  ) +
  annotate("text", x = max(org_cov$n_oblasts) * 0.98, y = max(org_cov$n_clusters) * 1.02,
           label = "Full-coverage", hjust = 1, vjust = 1,
           fontface = "bold", colour = "#0a9396", size = 4.2) +
  annotate("text", x = q_oblasts * 0.5, y = q_clusters * 0.5,
           label = "Specialists", hjust = 0.5, vjust = 0.5,
           colour = "grey55", size = 3.6, fontface = "italic") +
  scale_size_area(max_size = 14, breaks = c(5, 15, 30, 50),
                  name = "Total disbursed\n(US$ millions)") +
  scale_colour_manual(values = pal_orgtype, name = "Organisation type") +
  scale_x_continuous(breaks = seq(0, 25, 5)) +
  scale_y_continuous(breaks = seq(0, 14, 2)) +
  labs(
    title = "Who fits the UHF profile? Coverage map of Ukraine partners, 2019–2025",
    subtitle = paste0("Upper-right quadrant = ", n_full, " partners (", pct_full,
                      "% of orgs) capturing ", pct_bud_full, "% of disbursed budget"),
    x = "Geographic coverage — distinct oblasts",
    y = "Sector coverage — distinct clusters",
    caption = "Thresholds: 75th percentile on each axis. Source: CBPF ProjectSummary + Location + Cluster, Feb 2026 extract."
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title     = element_text(face = "bold", size = 14, colour = "#005f73"),
    plot.subtitle  = element_text(size = 11, colour = "#555555"),
    panel.grid.minor = element_blank(),
    legend.position  = "right",
    legend.box       = "vertical"
  ) +
  guides(
    colour = guide_legend(order = 1, override.aes = list(size = 5)),
    size   = guide_legend(order = 2)
  )

UHF partner universe, 2019–2025. Oblast coverage vs cluster coverage; bubble size = total disbursed budget. Dashed lines mark the 75th-percentile thresholds. Full-coverage quadrant (upper-right) concentrates the largest, most trusted partners.

17 of 104 partners (16%) sit in the Full-coverage quadrant, but together they captured 46% of all UHF disbursements since 2019. Their median cumulative budget is US$20.9M, versus US$2.2M for Specialists — an order-of-magnitude gap that confirms coverage is the single strongest predictor of scale at the UHF.

8.2 Quadrant Breakdown by Organisation Type

Does the ideal-candidate profile favour a specific organisation type? The table below decomposes each quadrant by INGO / NNGO / UN Agency, showing both the partner count and the share of total budget that flowed to each cell.

Show code
q_tbl <- org_cov %>%
  group_by(Quadrant, OrgType) %>%
  summarise(
    n_orgs       = n(),
    total_budget = sum(total_budget, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  mutate(
    pct_bud = 100 * total_budget / sum(total_budget),
    cell = sprintf("%d orgs — US$%.1fM (%.1f%%)",
                   n_orgs, total_budget / 1e6, pct_bud)
  ) %>%
  select(Quadrant, OrgType, cell) %>%
  tidyr::pivot_wider(names_from = OrgType, values_from = cell, values_fill = "—") %>%
  mutate(Quadrant = factor(Quadrant,
                           levels = c("Full-coverage", "Sector generalist",
                                      "Area generalist", "Specialist"))) %>%
  arrange(Quadrant)

knitr::kable(
  q_tbl,
  caption = "Partner count and share of total disbursed budget by quadrant and organisation type (Ukraine UHF, 2019–2025)",
  col.names = c("Quadrant", "INGO", "NNGO", "UN Agency")
)
Partner count and share of total disbursed budget by quadrant and organisation type (Ukraine UHF, 2019–2025)
Quadrant INGO NNGO UN Agency
Full-coverage 8 orgs — US$174.3M (22.6%) 7 orgs — US$145.0M (18.8%) 2 orgs — US$37.1M (4.8%)
Sector generalist 10 orgs — US$47.2M (6.1%) 11 orgs — US$79.2M (10.3%) 1 orgs — US$7.5M (1.0%)
Area generalist 5 orgs — US$32.6M (4.2%) 3 orgs — US$18.6M (2.4%) 4 orgs — US$54.6M (7.1%)
Specialist 29 orgs — US$114.0M (14.8%) 23 orgs — US$42.3M (5.5%) 1 orgs — US$18.0M (2.3%)

Three readings emerge from the quadrant table:

  • The Full-coverage quadrant is mixed — it is not exclusively INGO territory. NNGOs and UN agencies both appear in meaningful numbers, confirming that broad footprint is achievable across organisation types. It is important to point that, in the case of Multipurpose Cash Asssistance (MPCA), UN agencies relied heavily on online registration, which means that no physical presence was actually achieved.
  • UN agencies cluster as Area generalists: they cover many oblasts but relatively few clusters (they tend to specialise by mandate — UNHCR on protection/shelter, IOM on mobility, FAO on food security).
  • NNGOs split in two: a small cohort joins the Full-coverage quadrant (e.g. ICF Caritas Ukraine, Proliska, Charity Fund “New Way”), while most remain Specialists with 1–2 oblasts and 1–3 clusters — a realistic starting point for first-time UHF applicants.

8.3 The Full-Coverage Cohort: Named List

Finally, the organisations that currently meet the ideal profile. This is the disbursement-based short-list: partners the UHF has already validated at scale on both coverage axes.

Show code
top_cov <- org_cov %>%
  filter(Quadrant == "Full-coverage") %>%
  arrange(desc(total_budget)) %>%
  mutate(
    `Total disbursed` = scales::dollar(total_budget, scale = 1e-6, suffix = "M", accuracy = 0.1),
    `Median grant`    = scales::dollar(median_grant, scale = 1e-6, suffix = "M", accuracy = 0.1)
  ) %>%
  select(
    Organisation  = OrganizationName,
    Type          = OrgType,
    Oblasts       = n_oblasts,
    Clusters      = n_clusters,
    `Years active` = n_years,
    Projects       = n_projects,
    `Total disbursed`,
    `Median grant`
  )

knitr::kable(
  top_cov,
  caption = paste0("Full-coverage cohort — ", nrow(top_cov),
                   " organisations in the upper-right quadrant (Ukraine UHF, 2019–2025), ranked by total disbursed budget"),
  align = c("l", "l", "c", "c", "c", "c", "r", "r")
)
Full-coverage cohort — 17 organisations in the upper-right quadrant (Ukraine UHF, 2019–2025), ranked by total disbursed budget
Organisation Type Oblasts Clusters Years active Projects Total disbursed Median grant
Danish Refugee Council INGO 19 7 5 11 $47.4M $4.7M
ICF Caritas Ukraine NNGO 17 8 7 13 $39.0M $3.5M
Agency for Technical Cooperation and Development INGO 24 7 5 11 $38.8M $4.9M
People in Need INGO 15 4 7 11 $32.9M $3.1M
Charitable Organization ‘Charitable Fund ’The Right to Protection’ NNGO 17 5 4 8 $31.9M $4.5M
Proliska NNGO 14 8 7 11 $28.9M $2.7M
International Organization for Migration UN Agency 17 5 4 8 $28.1M $4.0M
Norwegian Refugee Council INGO 24 7 6 8 $23.6M $2.1M
Charitable Organization ‘Charity Fund ’POSMISHKA UA’ NNGO 13 7 4 11 $20.9M $2.4M
Charitable organization “Charitable foundation “ROKADA” NNGO 13 5 3 6 $12.7M $2.4M
Save the Children Fund INGO 9 8 7 12 $12.5M $0.7M
United Nations Children’s Fund UN Agency 19 5 3 5 $9.0M $0.5M
Polish Humanitarian Action INGO 9 7 5 8 $8.6M $1.1M
CHARITABLE ORGANIZATION “CHARITY FOUNDATION “EAST-SOS” NNGO 12 5 2 4 $8.1M $1.9M
Triangle Generation Humanitaire INGO 12 5 5 5 $6.0M $1.0M
Terre des hommes - Aide a l’enfance dans le monde - Fondation INGO 10 4 3 3 $4.5M $1.7M
Ukrainian Deminers Association NNGO 21 5 3 5 $3.5M $0.7M

Implications for partner development. If the UHF wishes to expand this cohort — particularly by growing the NNGO pipeline — investment should target the adjacent quadrants: Area generalists (broad footprint, narrow sector) need capacity-building to add clusters; Sector generalists (multi-sector, narrow footprint) need support to expand geographically, often via consortium or sub-granting arrangements. Specialists are where the NNGO on-ramp begins.


9 Health Funding Recipients in Ukraine (2019–2025)

Since the Ukraine Humanitarian Fund was established in 2019, the Health cluster has received a significant share of total allocations. This section identifies every organisation that has received health-earmarked funding and tracks how the recipient landscape has evolved over time.

9.1 All-time Health Funding by Recipient

Show code
top_n_show <- nrow(ukr_health_orgs)   # show all recipients

plot_df <- ukr_health_orgs %>%
  slice_head(n = top_n_show) %>%
  mutate(
    OrganizationName = stringr::str_trunc(OrganizationName, width = 45),
    OrganizationName = factor(OrganizationName,
                              levels = rev(unique(OrganizationName)))
  )

ggplot(plot_df,
       aes(x = OrganizationName, y = TotalHealth_M, colour = OrgType)) +
  geom_segment(aes(xend = OrganizationName, y = 0, yend = TotalHealth_M),
               linewidth = 0.8, alpha = 0.6) +
  geom_point(aes(size = n_grants), alpha = 0.9) +
  geom_text(aes(label = paste0("$", round(TotalHealth_M, 1), "M")),
            hjust = -0.2, size = 3.0, fontface = "bold") +
  coord_flip() +
  scale_colour_manual(values = orgtype_colors, name = "Org. type") +
  scale_size_continuous(name = "No. of grants", range = c(3, 9), breaks = c(1, 3, 5, 8)) +
  scale_y_continuous(
    labels = scales::dollar_format(suffix = "M", accuracy = 0.1),
    expand = expansion(mult = c(0, 0.28))
  ) +
  labs(
    title    = "Ukraine UHF: Health Funding Recipients (2019–2025)",
    subtitle = "All organisations that received Health-cluster funding since the fund was established.\nDot size = number of grants received.",
    x        = NULL,
    y        = "Total health-earmarked funding (USD millions)",
    caption  = "Source: CBPF Cluster + ProjectSummary datasets, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title         = element_text(face = "bold", size = 14, colour = "#005f73"),
    plot.subtitle      = element_text(size = 11, colour = "#555555"),
    axis.text.y        = element_text(size = 9.5),
    legend.position    = "right",
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank()
  )

39 organisations have received Health-cluster funding from the Ukraine Humanitarian Fund since 2019, together accounting for $62.4M in health-earmarked allocations. The top recipient — World Health Organization — captured 16.7% of all health funding, and the top three organisations together account for 37.5%. The recipient pool comprises 4 UN agencies, 22 INGOs and 13 NNGOs, reflecting the mixed multi-channel delivery model used in the Ukraine response.

9.2 Health Funding by Organisation Over Time

Show code
# Re-order orgs by total funding for consistent axis
org_order_health <- ukr_health_orgs %>%
  arrange(TotalHealth_M) %>%
  pull(OrganizationName)

heatmap_df <- ukr_health_yearly %>%
  mutate(
    OrganizationName = stringr::str_trunc(OrganizationName, width = 45),
    OrganizationName = factor(OrganizationName,
                              levels = stringr::str_trunc(org_order_health, width = 45))
  ) %>%
  complete(OrganizationName, AllocationYear = 2019:2025,
           fill = list(Budget_M = NA))

ggplot(heatmap_df,
       aes(x = as.factor(AllocationYear),
           y = OrganizationName,
           fill = Budget_M)) +
  geom_tile(colour = "white", linewidth = 0.4) +
  geom_text(aes(label = ifelse(!is.na(Budget_M),
                               paste0("$", round(Budget_M, 1), "M"),
                               "")),
            size = 2.8, colour = "white", fontface = "bold") +
  scale_fill_gradient(
    low     = "#94d2bd",
    high    = "#005f73",
    na.value = "grey93",
    name    = "Health budget\n(USD millions)",
    labels  = scales::dollar_format(suffix = "M", accuracy = 0.1)
  ) +
  labs(
    title    = "Ukraine UHF: Health Funding Per Recipient and Year (2019–2025)",
    subtitle = "Grey cells = no health-cluster grant in that year",
    x        = "Allocation Year",
    y        = NULL,
    caption  = "Source: CBPF Cluster + ProjectSummary datasets, Feb 2026 extract"
  ) +
  theme_minimal(base_size = 11) +
  theme(
    plot.title       = element_text(face = "bold", size = 14, colour = "#005f73"),
    plot.subtitle    = element_text(size = 11, colour = "#555555"),
    axis.text.y      = element_text(size = 9),
    axis.text.x      = element_text(size = 10, face = "bold"),
    panel.grid       = element_blank(),
    legend.position  = "right"
  )

The heatmap makes the temporal fragmentation of health delivery visible: organisations that carried health programming before the full-scale invasion are largely replaced post-2022 by a different set of partners with the absorptive capacity to deploy larger grants. This matters for continuity: UHF health funding is now concentrated in a handful of INGOs and a growing NNGO cohort, with no resident UN health convenor inside the fund. Monitoring whether this reconfiguration preserves technical standards, coordination, and last-mile reach will be an important diagnostic for the fund’s health portfolio going forward.

It is clear that the UHF’s health funding strategy is based on absorption rather than field experience or technical expertise. The top health recipients are not specialised health actors but rather the largest, most trusted partners with the capacity to deploy large grants at speed. Moreover, the technical miscalculation of not considering Local Health facilities as partnership candidates makes this funding impact even more questioable: https://baena.blog/00.-STOCK/The-Mistake-of-Excluding-Local-Health-Facilities-from-Localization-Targets-in-Ukraine

Furthermore, assessing the true cost-effectiveness of concentrating funding among a few ‘official’ partners remains impossible, given that 5W (Who, What, Where, When, and for Whom) operational data is frequently partial or unpublished. Despite these data constraints, a distinct profile emerges for the preferred health partners: they are predominantly Anglo-Saxon organizations—including some headquartered in non-WHO member states—that possessed no operational footprint in Ukraine prior to the 2022 escalation.


10 Cross-Check: UHF Annual Reports vs. Data

A dataset-based analysis is only useful if it agrees with — or carefully disagrees with — the fund’s own published narrative. This chapter reconciles the figures in this report against the UHF Annual Reports for 2022, 2023 and 2024. The exercise serves three purposes: (i) validate the data pipeline; (ii) surface reporting inconsistencies the reader should know about; (iii) highlight structural shifts the ARs document that are easy to miss in a year-by-year view.

10.1 Reconciliation Status

Show code
ar_rec <- tibble::tribble(
  ~Year, ~Metric,                  ~`Raw data`,                                 ~`AR figure`,                                     ~Verdict,
  "2022", "Total allocations",     "$192.7M / 56 partners / 110 projects",       "$192M / 56 partners / 109 projects",             "Match (1-project delta)",
  "2022", "Org-type split",         "INGO $82M · NNGO $45M · UN $66M",           "INGO $82M · UN $66M · NNGO $44M (direct)",        "Match",
  "2023", "Total allocations",     "$183.7M / 49 partners / 74 projects",        "$181.2M / 49 partners",                           "Close (+$2.5M)",
  "2023", "Org-type split",         "INGO $104.0M · NNGO $38.3M · UN $41.4M",    "INGO $92M · NNGO $56.8M · UN $32.4M",             "Mismatch — see below",
  "2024", "Total allocations",     "$164.3M / 58 partners / 73 projects",        "$164.3M / 58 partners / 73 projects",             "Exact match",
  "2024", "Org-type split",         "INGO $84.6M · NNGO $74.8M · UN $5.0M",      "INGO $84.6M (51.9%) · NNGO $73.3M (45%) · UN $5M", "Match"
)

knitr::kable(ar_rec,
             caption = "Reconciliation: raw CBPF extract (Feb 2026) against UHF Annual Report headline figures.",
             align  = c("l","l","l","l","l"))
Reconciliation: raw CBPF extract (Feb 2026) against UHF Annual Report headline figures.
Year Metric Raw data AR figure Verdict
2022 Total allocations $192.7M / 56 partners / 110 projects $192M / 56 partners / 109 projects Match (1-project delta)
2022 Org-type split INGO $82M · NNGO $45M · UN $66M INGO $82M · UN $66M · NNGO $44M (direct) Match
2023 Total allocations $183.7M / 49 partners / 74 projects $181.2M / 49 partners Close (+$2.5M)
2023 Org-type split INGO $104.0M · NNGO $38.3M · UN $41.4M INGO $92M · NNGO $56.8M · UN $32.4M Mismatch — see below
2024 Total allocations $164.3M / 58 partners / 73 projects $164.3M / 58 partners / 73 projects Exact match
2024 Org-type split INGO $84.6M · NNGO $74.8M · UN $5.0M INGO $84.6M (51.9%) · NNGO $73.3M (45%) · UN $5M Match

The 2023 org-type breakdown does not reconcile. The 2023 AR reports $56.8M to NNGOs (≈30% of the envelope); the raw CBPF extract shows $38.3M direct to National NGO entities. The most plausible reconciliation is that the AR definition includes sub-granted funding flowing from INGO prime recipients to Ukrainian NNGO sub-implementers, and possibly bundles Red Cross / partially-national entities into the NNGO category. When quoting either figure in discussion with external audiences, it is advisable to cite the definition used (direct-partner classification vs end-beneficiary-entity classification).

10.2 Structural Shifts the ARs Document

Three cross-year trends in the ARs are not obvious from any single year of data and deserve explicit highlighting.

10.2.1 1. The UN Agency exit

Show code
un_exit <- project_summary %>%
  filter(PooledFundName == "Ukraine",
         AllocationYear %in% 2022:2025,
         !is.na(Budget), Budget > 0) %>%
  mutate(OT = case_when(
    OrganizationType == "International NGO" ~ "INGO",
    OrganizationType == "National NGO"      ~ "NNGO",
    OrganizationType == "UN Agency"         ~ "UN Agency",
    TRUE                                    ~ "Other"
  )) %>%
  group_by(AllocationYear, OT) %>%
  summarise(amt_m = sum(Budget) / 1e6, .groups = "drop")

ggplot(un_exit, aes(x = AllocationYear, y = amt_m, colour = OT, group = OT)) +
  geom_line(linewidth = 1.3) +
  geom_point(size = 3.5) +
  geom_text(aes(label = scales::dollar(amt_m, suffix = "M", accuracy = 1)),
            vjust = -1.0, size = 3.1, fontface = "bold", show.legend = FALSE) +
  scale_colour_manual(values = c(
    "INGO"      = "#005f73",
    "NNGO"      = "#2a9d8f",
    "UN Agency" = "#e63946",
    "Other"     = "#adb5bd"
  ), name = NULL) +
  scale_x_continuous(breaks = 2022:2025) +
  scale_y_continuous(labels = scales::dollar_format(suffix = "M"),
                     expand = expansion(mult = c(0.05, 0.15))) +
  labs(
    title = "UN Agency allocations collapsed from $66M to $5M in two years",
    subtitle = "Ukraine UHF, 2022–2025, USD millions by organisation type",
    x = "Allocation year", y = "Disbursed (USD millions)",
    caption = "Source: CBPF ProjectSummary dataset, Feb 2026 extract. 2024 AR: UN would no longer be funded unless absolutely necessary."
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14, colour = "#005f73"),
    plot.subtitle = element_text(size = 11, colour = "#555555"),
    legend.position = "top",
    panel.grid.minor = element_blank()
  )

The 2024 Annual Report is explicit: “UN would no longer be funded unless absolutely necessary.” The data tracks this policy: UN Agency allocations fell from $66.0M (2022) → $41.4M (2023) → $5.0M (2024) — a 92% drop in two years. Displaced volume flowed primarily to DRC, ICF Caritas Ukraine and a broadened NNGO tier (see Concentration chapter).

10.2.2 2. UHF’s growing weight in the CBPF system

Per the Annual Reports:

  • 2023 AR: UHF = 17% of global CBPF allocations, 7% of the Ukraine HRP.
  • 2024 AR: UHF = 20% of global CBPF allocations, >10% of the Ukraine HNRP — “largest CBPF for the third consecutive year.”

One in every five CBPF dollars globally now flows through UHF. The “7th of 28” framing used in parts of this report for reserve-allocation share is accurate for that narrow metric, but understates UHF’s overall systemic weight.

10.2.3 3. Contributions volatility with rising localisation

Show code
ar_tbl <- tibble::tribble(
  ~Year, ~`Contributions (AR)`, ~`Donors (AR)`, ~`Allocations (AR)`, ~`NNGO direct (AR)`, ~`WLO funding (AR)`,
  "2022", "$327M",             "28",           "$192M",             "33%",                "n/r",
  "2023", "$182M (−44% YoY)",  "26",           "$181M",             "30%",                "$9M (~5%)",
  "2024", "$232M",             "27",           "$164M",             "60% ($96M)",         "$50.4M (31%)"
)

knitr::kable(ar_tbl,
             caption = "Headline year-on-year shifts reported in the UHF Annual Reports 2022, 2023 and 2024. WLO = Women-Led Organisation funding.",
             align  = c("l","r","c","r","r","r"))
Headline year-on-year shifts reported in the UHF Annual Reports 2022, 2023 and 2024. WLO = Women-Led Organisation funding.
Year Contributions (AR) Donors (AR) Allocations (AR) NNGO direct (AR) WLO funding (AR)
2022 $327M 28 $192M 33% n/r
2023 $182M (−44% YoY) 26 $181M 30% $9M (~5%)
2024 $232M 27 $164M 60% ($96M) $50.4M (31%)

The 2023 contributions drop (−44% YoY) is larger than the allocations line suggests because UHF ran down prior-year cash balances. Despite partial 2024 recovery, UHF’s contribution base remains 29% below its 2022 peak — the fund’s relative systemic importance is rising on a shrinking absolute base. Meanwhile WLO-earmarked funding jumped from ≈5% to 31% of the envelope in a single year, the most dramatic single-year shift in any policy category documented in the ARs.

10.3 Allocation Cadence

All three ARs document a consistent cadence: a large early-year Reserve or Standard allocation, a mid-year Reserve, and a late-year top-up. For partners, this is the operational rhythm UHF expects them to absorb.

Show code
cadence <- tibble::tribble(
  ~Year, ~`Round 1`,                ~`Round 2`,                ~`Round 3`,
  "2022", "RA Feb–Apr: $91M total",  "RA Jul: $26M",             "SA Sep: $75M (largest-ever at the time)",
  "2023", "RA1 Jan: $52M",           "SA1 Mar: $69M",            "RA2 Aug: $60M",
  "2024", "SA1 Jan: $76.2M",         "RA1 Jul: $66.6M",          "RA2 Oct: $21.5M",
  "2025", "SA1 Jan: $86.8M (new record)", "—",                    "—"
)

knitr::kable(cadence,
             caption = "UHF allocation rounds by year, as reported in the Annual Reports. 2025 reflects the January Standard Allocation documented in the 2024 AR.",
             align  = c("l","l","l","l"))
UHF allocation rounds by year, as reported in the Annual Reports. 2025 reflects the January Standard Allocation documented in the 2024 AR.
Year Round 1 Round 2 Round 3
2022 RA Feb–Apr: $91M total RA Jul: $26M SA Sep: $75M (largest-ever at the time)
2023 RA1 Jan: $52M SA1 Mar: $69M RA2 Aug: $60M
2024 SA1 Jan: $76.2M RA1 Jul: $66.6M RA2 Oct: $21.5M
2025 SA1 Jan: $86.8M (new record)

Implication for candidate partners. An organisation able to sit in the UHF’s core-20 needs to be able to respond to two Reserve triggers per calendar year and absorb a Standard allocation of $70M–$90M distributed across 30–50 partners. The cap-binding pattern documented in the Concentration chapter is the partner-level expression of this cadence.

11 Conclusions

The Ukraine Humanitarian Pooled Fund (UHF) represents a unique operational model within the global CBPF system. Driven by an unprecedented financial envelope and an aggressive localization mandate, the fund has redefined how humanitarian financing is distributed in a high-intensity, sustained conflict.

Several defining characteristics set the UHF apart from its global peers:

Scale Over Concentration: While the UHF appears highly concentrated, metric analysis (HHI, top-5 share) proves it is a broad-partnership fund. The illusion of concentration is an arithmetic byproduct of its massive envelope interacting with the standard $5M grant ceiling. Consequently, the UHF routinely issues max-sized grants to a wide array of partners, creating a flat-topped distribution unseen in any other country fund.

True Localization: Globally, International NGOs consistently receive grants 25% larger than National NGOs. In Ukraine, this paradigm has been inverted. Following the full-scale invasion—and accelerated by a deliberate reduction in UN Agency funding (falling from $66M in 2022 to $5M in 2024)—NNGOs now receive larger median grants than INGOs.

The Core-20 and Fiduciary Reality: The fund relies heavily on a stable core of 20 organizations capable of absorbing large-scale, repeated funding rounds. For several NNGOs, this has resulted in UHF volumes that dwarf their entire pre-war annual revenues. While this highlights successful capacity building, it also establishes deep structural dependencies that will require careful fiduciary and transitional management.

The “Full-Coverage” Premium: The data reveals a clear “ideal candidate” profile. Organizations that operate across multiple oblasts and sectors (Full-coverage partners) capture the vast majority of the disbursed budget. For NNGOs looking to scale, the empirical path is to evolve from geographic or sector specialists into area or sector generalists via consortiums or geographic expansion.

Health Sector Fragmentation: Despite large individual grant sizes, the health sector’s implementing landscape has experienced significant churn. The exit of historical anchor agencies like the WHO from the direct recipient pool leaves the health portfolio increasingly reliant on a shifting mix of INGOs and NNGOs, raising questions about long-term technical continuity and coordination.

Maturing Allocation Strategies: The UHF’s historical over-reliance on Reserve Allocations (51.9% globally compared to a 40.2% baseline) is beginning to self-correct. This reflects a maturation of the response: acknowledging that while humanitarian needs in Ukraine remain incredibly high, the foundational operational environment is no longer defined by the extreme volatility of 2022.

The positive: UHF has proven that it is possible to localize a massive humanitarian response without defaulting to a handful of UN mega-grants. However, sustaining this model requires ongoing vigilance regarding partner dependency, sector-specific technical leadership, and the careful balancing of emergency agility with predictable, standard funding cycles.

Despite its scale, critical transparency gaps remain within the fund. The blurred distinction between standard and reserve allocations, the absence of published pipeline data, and limited visibility into partner underspending and audit results introduce structural biases into partner selection and retention. Ultimately, this opacity risks fostering a closed ecosystem of entrenched, de facto ‘official’ partners rather than maintaining a truly competitive and evidence-based allocation process.


I specialize in task-based consulting services for data analytics. You can contact me at baena.ai/contact

Author: Jesus Baena

Report generated on 2026-04-24 using Quarto.

12 References

  • OCHA. (2026, February). CBPF ProjectSummary, Cluster and Location Datasets. OCHA Data Explorer. Retrieved from https://cbpfapi.unocha.org/vo1/odata

  • OCHA & UHF. (2023). Ukraine Humanitarian Fund 2022 Annual Report. United Nations Office for the Coordination of Humanitarian Affairs.

  • OCHA & UHF. (2024). Ukraine Humanitarian Fund 2023 Annual Report. United Nations Office for the Coordination of Humanitarian Affairs.

  • OCHA & UHF. (2025). Ukraine Humanitarian Fund 2024 Annual Report. United Nations Office for the Coordination of Humanitarian Affairs.

  • OCHA. (2024). CBPF Global Guidelines and Eligibility Criteria. United Nations Office for the Coordination of Humanitarian Affairs.