Skip to content

Cross-Sheet Reference Lookup

Enrich data by looking up values from another sheet (like VLOOKUP).

User Story

You have two sheets: - Orders sheet: Customer orders with customer ID and order amount - Customers sheet: Master list of customers with ID, name, and region

You need to:

  • Look up customer details (name, region) based on customer ID from the orders
  • Add the customer name and region to each order row
  • Handle missing customer IDs gracefully (skip or mark as "Unknown")
  • Validate all required lookup values exist before processing

With gaslamp

JavaScript
function cookbook_crossSheetLookup() {
  // Step 1: Define schemas for both data sources
  const orderSchema = {
    customer_id: gaslamp.FlameGuards.isNumber,
    amount:      gaslamp.FlameGuards.isNumber,
  };

  const customerSchema = {
    id:     gaslamp.FlameGuards.isNumber,
    name:   gaslamp.FlameGuards.isString,
    region: gaslamp.FlameGuards.isString,
  };

  const workbook = SpreadsheetApp.getActiveSpreadsheet();

  // Step 2: Load and validate orders
  const ordersSheet = workbook.getSheetByName('Orders');
  const ordersDF = gaslamp.BareFrame.fromSheet(ordersSheet);
  const { passed: validOrders, failed: invalidOrders } = gaslamp.FlameFrame.from(ordersDF, orderSchema);

  if (invalidOrders.length > 0) {
    console.error('Invalid orders found:');
    console.error(invalidOrders.display());
    return;
  }

  // Step 3: Load and validate customers (reference data)
  const customersSheet = workbook.getSheetByName('Customers');
  const customersDF = gaslamp.BareFrame.fromSheet(customersSheet);
  const { passed: validCustomers, failed: invalidCustomers } = gaslamp.FlameFrame.from(customersDF, customerSchema);

  if (invalidCustomers.length > 0) {
    console.error('Invalid customers found:');
    console.error(invalidCustomers.display());
    return;
  }

  // Step 4: Create lookup map from customer ID to details
  // Convert customers to an object map for O(1) lookup
  const customerMap = {};
  validCustomers.toRows().forEach((row) => {
    const customerId = row.get('id');
    customerMap[customerId] = {
      name:   row.get('name'),
      region: row.get('region'),
    };
  });

  // Step 5: Enrich orders with customer details via lookup
  const enriched = validOrders.withColumn('customer_name', (row) => {
    const customerId = row.get('customer_id');
    return customerMap[customerId]?.name || 'Unknown';
  }).withColumn('region', (row) => {
    const customerId = row.get('customer_id');
    return customerMap[customerId]?.region || 'Unknown';
  });

  // Step 6: Identify orders with missing customer references
  const hasCustomer = gaslamp.Expression.col('customer_name').ne('Unknown');
  const validEnriched = enriched.filter(hasCustomer.toFunction());
  const missingCustomers = enriched.filter(
    hasCustomer.not().toFunction()
  );

  console.log(`Enriched ${validEnriched.length} orders with customer details`);
  if (missingCustomers.length > 0) {
    console.warn(`${missingCustomers.length} orders have unknown customers:`, missingCustomers.display());
  }

  // Step 7: Write enriched data to output sheet
  const outputSheet = workbook.getSheetByName('Enriched Orders')
    ?? workbook.insertSheet('Enriched Orders');

  validEnriched.toSheet(outputSheet);
  console.log('Enriched orders exported');
}

Key gaslamp features used:

  • Dual schema validation — validate both source and reference data independently
  • .toRows() and .forEach() — convert DataFrame to rows for building lookup maps
  • Object notation ?. (optional chaining) — safe access to lookup results with fallback
  • .withColumn() chaining — add multiple lookup columns sequentially
  • Expression negation — filter for missing references with .not()
  • Graceful fallback — mark missing references as "Unknown" instead of failing

Without gaslamp

JavaScript
function cookbook_crossSheetLookup_vanilla() {
  const workbook = SpreadsheetApp.getActiveSpreadsheet();

  // Step 1: Load and validate orders
  const ordersSheet = workbook.getSheetByName('Orders');
  const ordersRange = ordersSheet.getDataRange();
  const ordersValues = ordersRange.getValues();
  const ordersHeaders = ordersValues[0];
  const ordersRows = ordersValues.slice(1);

  const orderCustomerIdIdx = ordersHeaders.indexOf('customer_id');
  const orderAmountIdx = ordersHeaders.indexOf('amount');

  const validOrders = [];
  const invalidOrders = [];
  for (const row of ordersRows) {
    if (typeof row[orderCustomerIdIdx] !== 'number' || typeof row[orderAmountIdx] !== 'number') {
      invalidOrders.push(row);
      continue;
    }
    validOrders.push(row);
  }

  if (invalidOrders.length > 0) {
    console.error('Invalid orders found:', invalidOrders);
    return;
  }

  // Step 2: Load and validate customers
  const customersSheet = workbook.getSheetByName('Customers');
  const customersRange = customersSheet.getDataRange();
  const customersValues = customersRange.getValues();
  const customersHeaders = customersValues[0];
  const customersRows = customersValues.slice(1);

  const customerIdIdx = customersHeaders.indexOf('id');
  const customerNameIdx = customersHeaders.indexOf('name');
  const customerRegionIdx = customersHeaders.indexOf('region');

  const validCustomers = [];
  const invalidCustomers = [];
  for (const row of customersRows) {
    if (typeof row[customerIdIdx] !== 'number' || typeof row[customerNameIdx] !== 'string' || typeof row[customerRegionIdx] !== 'string') {
      invalidCustomers.push(row);
      continue;
    }
    validCustomers.push(row);
  }

  if (invalidCustomers.length > 0) {
    console.error('Invalid customers found:', invalidCustomers);
    return;
  }

  // Step 3: Manually build lookup map from customers
  const customerMap = {};
  for (const row of validCustomers) {
    const customerId = row[customerIdIdx];
    customerMap[customerId] = {
      name:   row[customerNameIdx],
      region: row[customerRegionIdx],
    };
  }

  // Step 4: Manually enrich orders with lookups
  const enriched = [];
  const missingCustomers = [];
  for (const row of validOrders) {
    const customerId = row[orderCustomerIdIdx];
    const customer = customerMap[customerId];

    const customerName = customer ? customer.name : 'Unknown';
    const region = customer ? customer.region : 'Unknown';

    enriched.push([...row, customerName, region]);

    if (!customer) {
      missingCustomers.push(row);
    }
  }

  // Step 5: Filter out rows with missing customers
  const validEnriched = enriched.filter((row) => row[row.length - 2] !== 'Unknown');

  console.log(`Enriched ${validEnriched.length} orders with customer details`);
  if (missingCustomers.length > 0) {
    console.warn(`${missingCustomers.length} orders have unknown customers`);
  }

  // Step 6: Write enriched data to output sheet
  const outputSheet = workbook.getSheetByName('Enriched Orders')
    ?? workbook.insertSheet('Enriched Orders');

  if (validEnriched.length > 0) {
    // Must manually add headers with new lookup columns
    const outputHeaders = [...ordersHeaders, 'customer_name', 'region'];
    outputSheet.getRange(1, 1, 1, outputHeaders.length).setValues([outputHeaders]);

    // Write enriched rows
    outputSheet.getRange(2, 1, validEnriched.length, validEnriched[0].length)
      .setValues(validEnriched);
  }

  console.log('Enriched orders exported');
}

Without gaslamp, you need to:

  • Manually load and validate both source and reference data with separate loops
  • Manually index headers for both sheets and track column positions
  • Manually type-check both datasets with nested conditions
  • Manually build lookup map by iterating through reference data
  • Manually enrich data with nested ternary operators or if-else for each lookup column
  • Manually track which rows had missing references separately
  • Manually construct headers by spreading original headers + new lookup column names
  • Manually filter results by checking the added columns
  • Manually calculate range dimensions for writing multi-source data

Key Techniques

  • Validate both sources: Always validate source and reference data independently before lookup
  • Build lookup maps: Convert reference data to objects for O(1) lookup instead of nested loops
  • Safe defaults: Use optional chaining ?. with fallback values for missing references
  • Track missing references: Keep separate DataFrame of unmatched rows for auditing
  • Filter after enrichment: Remove unmatched rows in post-processing, not during lookup
  • Chainable enrichment: Use .withColumn() to add multiple lookup columns sequentially

See Select & Drop to remove intermediate lookup columns if needed after enrichment.