Skip to main content
RapidDev - Software Development Agency
retool-tutorial

How to Handle Data Transformations in Retool

Handle complex data transformations in Retool using standalone transformers in the Code tab. Chain .map(), .filter(), and .reduce() for reshaping. Join two queries using a lookup object built from one query's data. Use the bundled Lodash library (_ variable) for grouping, ordering, and multi-source merges. Transformers must return a value and are read-only.

What you'll learn

  • How to use map/filter/reduce chains to reshape query data in Retool transformers
  • How to join data from two queries using a lookup map pattern
  • How to aggregate data (sum, count, average) across rows using reduce
  • How to use Lodash utilities like _.groupBy, _.zipWith, and _.orderBy in transformers
  • Patterns for multi-source data merging when building dashboards
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner10 min read20-25 minRetool Cloud and Self-hostedLast updated March 2026RapidDev Engineering Team
TL;DR

Handle complex data transformations in Retool using standalone transformers in the Code tab. Chain .map(), .filter(), and .reduce() for reshaping. Join two queries using a lookup object built from one query's data. Use the bundled Lodash library (_ variable) for grouping, ordering, and multi-source merges. Transformers must return a value and are read-only.

Quick facts about this guide
FactValue
ToolRetool
DifficultyBeginner
Time required20-25 min
CompatibilityRetool Cloud and Self-hosted
Last updatedMarch 2026

Complex Data Reshaping Patterns for Retool Apps

Most real-world Retool apps need to combine, reshape, and aggregate data from multiple sources. A sales dashboard might join user records with order history, group by month, and compute running totals. An operations tool might merge data from two APIs and filter by business rules.

This tutorial covers the transformation patterns that power these scenarios: array method chains for single-source reshaping, lookup map joins for merging two queries, reduce-based aggregations for statistics, Lodash utilities for complex grouping, and _.zipWith for parallel array merges. All examples use Retool's standalone transformer pattern.

Prerequisites

  • Understanding of Retool standalone transformers (Code tab → Transformer)
  • JavaScript knowledge: array map, filter, reduce methods
  • At least one query returning data in your Retool app
  • Optional: familiarity with Lodash (bundled as _ in Retool)

Step-by-step guide

1

Single-Source Transformation: Map/Filter/Reduce Chains

The most common transformation pattern chains .filter(), .map(), and other array methods to reshape a single query's output. Always start with a null guard, then chain operations in a logical order: filter first (reduces dataset), then map (transforms remaining rows).

typescript
1// Transformer: processedOrders
2// Input: query1.data = [{ id, user_id, amount, status, created_at, items: [...] }]
3
4const orders = query1.data || [];
5
6return orders
7 // 1. Filter: only completed orders
8 .filter(order => order.status === 'completed')
9 // 2. Map: reshape and add computed fields
10 .map(order => ({
11 id: order.id,
12 userId: order.user_id,
13 amount: Number(order.amount || 0),
14 amountFormatted: Number(order.amount || 0).toLocaleString('en-US', {
15 style: 'currency', currency: 'USD'
16 }),
17 itemCount: Array.isArray(order.items) ? order.items.length : 0,
18 createdDate: moment(order.created_at).format('MMM D, YYYY'),
19 dayOfWeek: moment(order.created_at).format('dddd')
20 }))
21 // 3. Sort by amount descending
22 .sort((a, b) => b.amount - a.amount);

Expected result: The transformer returns a filtered, reshaped, and sorted array of order objects.

2

Join Two Queries Using a Lookup Map

The most efficient way to join two datasets in JavaScript is building a lookup object from one, then using it while mapping over the other. This is O(n+m) compared to O(n*m) for nested loops — crucial when both datasets have thousands of rows. Example: join users (query1) with their aggregate stats (query2).

typescript
1// Transformer: usersWithStats
2// query1.data = [{ id, name, email, department }]
3// query2.data = [{ user_id, total_orders, total_revenue, avg_rating }]
4
5const users = query1.data || [];
6const stats = query2.data || [];
7
8// Build lookup map: O(n)
9const statsMap = stats.reduce((map, stat) => {
10 map[stat.user_id] = stat;
11 return map;
12}, {});
13// OR with lodash: const statsMap = _.keyBy(stats, 'user_id');
14
15// Join: O(m)
16return users.map(user => {
17 const userStats = statsMap[user.id] || {};
18 return {
19 ...user,
20 totalOrders: userStats.total_orders ?? 0,
21 totalRevenue: Number(userStats.total_revenue ?? 0),
22 avgRating: Number(userStats.avg_rating ?? 0).toFixed(1),
23 isHighValue: (userStats.total_revenue ?? 0) >= 1000
24 };
25});

Expected result: Each user object is enriched with their stats data from the second query, with safe fallbacks for users with no stats.

3

Aggregate Data with Reduce

Use .reduce() to compute aggregated statistics across all rows: totals, averages, counts, and maximums. Return an object with computed metrics for binding to stat cards, charts, or summary rows.

typescript
1// Transformer: orderStats
2// Computes summary statistics for a dashboard
3
4const orders = query1.data || [];
5
6if (orders.length === 0) {
7 return { total: 0, count: 0, average: 0, maxOrder: 0 };
8}
9
10const stats = orders.reduce((acc, order) => {
11 const amount = Number(order.amount || 0);
12 return {
13 total: acc.total + amount,
14 count: acc.count + 1,
15 maxOrder: Math.max(acc.maxOrder, amount),
16 completedCount: acc.completedCount + (order.status === 'completed' ? 1 : 0),
17 pendingCount: acc.pendingCount + (order.status === 'pending' ? 1 : 0)
18 };
19}, { total: 0, count: 0, maxOrder: 0, completedCount: 0, pendingCount: 0 });
20
21return {
22 ...stats,
23 average: stats.count > 0 ? stats.total / stats.count : 0,
24 completionRate: stats.count > 0
25 ? (stats.completedCount / stats.count * 100).toFixed(1) + '%'
26 : '0%'
27};
28
29// Access in components:
30// {{ orderStats.value.total.toLocaleString('en-US', {style:'currency',currency:'USD'}) }}
31// {{ orderStats.value.completionRate }}

Expected result: orderStats.value contains an object with total revenue, count, average, and completion rate.

4

Group Data with Lodash _.groupBy

Use _.groupBy() to split an array into groups by a field value. This is useful for building section headers, grouped charts, or per-category summaries. Lodash is bundled in Retool as the _ variable — no import needed.

typescript
1// Transformer: ordersByDepartment
2// Groups users by department for a grouped table
3
4const users = query1.data || [];
5
6// Group by department:
7const grouped = _.groupBy(users, 'department');
8// Result: { Engineering: [...], Sales: [...], Marketing: [...] }
9
10// Convert to array of group objects with summary stats:
11return Object.entries(grouped)
12 .map(([department, members]) => ({
13 department: department || 'Unassigned',
14 memberCount: members.length,
15 activeCount: members.filter(m => m.is_active).length,
16 totalRevenue: members.reduce((sum, m) => sum + Number(m.revenue || 0), 0),
17 members: members.map(m => m.name).join(', ')
18 }))
19 .sort((a, b) => b.totalRevenue - a.totalRevenue);
20
21// For a chart: bind to bar chart with department on x, totalRevenue on y

Expected result: The transformer returns an array of department summaries, each with member count and total revenue.

5

Merge Parallel Arrays with _.zipWith

When you have two arrays of the same length representing different metrics for the same items (e.g. category names from one query and revenue figures from another), use _.zipWith() to merge them into combined objects. This is common when APIs return separate arrays for labels and values.

typescript
1// Transformer: chartData
2// query1.data = [{ month: 'Jan', year: 2024 }, { month: 'Feb' }, ...]
3// query2.data = [{ revenue: 12450 }, { revenue: 18200 }, ...]
4// Both arrays are same length, ordered identically
5
6const months = query1.data || [];
7const revenues = query2.data || [];
8
9// Merge parallel arrays:
10return _.zipWith(months, revenues, (month, rev) => ({
11 label: `${month.month} ${month.year}`,
12 revenue: Number(rev?.revenue || 0),
13 revenueFormatted: Number(rev?.revenue || 0).toLocaleString('en-US', {
14 style: 'currency', currency: 'USD', minimumFractionDigits: 0
15 })
16}));
17
18// Alternative without Lodash:
19// return months.map((month, i) => ({
20// label: month.month + ' ' + month.year,
21// revenue: Number(revenues[i]?.revenue || 0)
22// }));

Expected result: chartData.value returns an array of combined label+revenue objects ready for a chart component.

6

Create Running Totals and Cumulative Sums

Use .reduce() to compute running totals or cumulative sums across an ordered array. This is useful for cumulative revenue charts, stock level tracking, or running balance displays.

typescript
1// Transformer: cumulativeRevenue
2// Adds a running total to each row
3
4const salesByDay = query1.data || [];
5
6let runningTotal = 0;
7
8return salesByDay.map(day => {
9 runningTotal += Number(day.revenue || 0);
10 return {
11 ...day,
12 dailyRevenue: Number(day.revenue || 0),
13 cumulativeRevenue: runningTotal,
14 cumulativeFormatted: runningTotal.toLocaleString('en-US', {
15 style: 'currency', currency: 'USD', minimumFractionDigits: 0
16 })
17 };
18});
19
20// For a Chart with two series:
21// Series 1: x = date, y = dailyRevenue (bar)
22// Series 2: x = date, y = cumulativeRevenue (line)

Expected result: Each row includes both its daily revenue and the cumulative total up to that day.

7

Deduplicate and Merge Duplicate Records

When APIs or queries return duplicate records, deduplicate using a Set or _.uniqBy(). For duplicate records where you need to merge data (e.g. sum order amounts for the same user appearing multiple times), use a reduce-based merge approach.

typescript
1// Deduplicate by id (keep first occurrence):
2return _.uniqBy(query1.data || [], 'id');
3
4// OR merge duplicates by summing amounts:
5const rows = query1.data || [];
6const merged = rows.reduce((acc, row) => {
7 const existing = acc.find(r => r.user_id === row.user_id);
8 if (existing) {
9 // Merge: add amounts
10 existing.total_amount = (existing.total_amount || 0) + Number(row.amount || 0);
11 existing.order_count = (existing.order_count || 0) + 1;
12 } else {
13 acc.push({
14 user_id: row.user_id,
15 name: row.name,
16 total_amount: Number(row.amount || 0),
17 order_count: 1
18 });
19 }
20 return acc;
21}, []);
22
23return merged.sort((a, b) => b.total_amount - a.total_amount);

Expected result: Duplicate records are merged into single entries with combined totals.

8

Multi-Source Dashboard Data Transformer

For dashboards pulling from multiple queries, create a single comprehensive transformer that joins and aggregates all data sources into one object. Binding multiple dashboard components to one transformer is cleaner than binding each to individual queries with separate transformations.

typescript
1// Transformer: dashboardData
2// Aggregates data from 3 queries for dashboard display
3// query1: users, query2: orders, query3: products
4
5const users = query1.data || [];
6const orders = query2.data || [];
7const products = query3.data || [];
8
9// KPIs
10const totalRevenue = orders.reduce((s, o) => s + Number(o.amount || 0), 0);
11const activeUsers = users.filter(u => u.is_active).length;
12const pendingOrders = orders.filter(o => o.status === 'pending').length;
13
14// Chart data: revenue by month
15const revenueByMonth = _.chain(orders)
16 .groupBy(o => moment(o.created_at).format('YYYY-MM'))
17 .map((monthOrders, month) => ({
18 month,
19 revenue: monthOrders.reduce((s, o) => s + Number(o.amount || 0), 0)
20 }))
21 .orderBy('month')
22 .value();
23
24// Top products by order frequency
25const topProducts = _.chain(orders)
26 .flatMap(o => o.items || [])
27 .groupBy('product_id')
28 .map((items, productId) => ({
29 productId,
30 count: items.length,
31 product: products.find(p => p.id === productId)
32 }))
33 .orderBy('count', 'desc')
34 .take(5)
35 .value();
36
37return { totalRevenue, activeUsers, pendingOrders, revenueByMonth, topProducts };

Expected result: A single dashboardData.value object contains all KPIs and chart-ready arrays for the entire dashboard.

Complete working example

Transformer: salesDashboard
1// Transformer: salesDashboard
2// Joins orders (query1) + users (query2) + products (query3)
3// Access as {{ salesDashboard.value.kpis }}, {{ salesDashboard.value.topUsers }}, etc.
4
5const orders = query1.data || [];
6const users = query2.data || [];
7const products = query3.data || [];
8
9// Build lookup maps
10const userMap = _.keyBy(users, 'id');
11const productMap = _.keyBy(products, 'id');
12
13// Orders enriched with user + product data
14const enrichedOrders = orders.map(order => ({
15 ...order,
16 userName: userMap[order.user_id]?.name ?? 'Unknown',
17 userDept: userMap[order.user_id]?.department ?? 'N/A',
18 amount: Number(order.amount || 0)
19}));
20
21// KPIs
22const kpis = {
23 totalRevenue: enrichedOrders.reduce((s, o) => s + o.amount, 0),
24 orderCount: enrichedOrders.length,
25 avgOrderValue: enrichedOrders.length > 0
26 ? enrichedOrders.reduce((s, o) => s + o.amount, 0) / enrichedOrders.length
27 : 0,
28 activeUsers: users.filter(u => u.is_active).length
29};
30
31// Revenue by month for line chart
32const revenueByMonth = _.chain(enrichedOrders)
33 .groupBy(o => moment(o.created_at).format('YYYY-MM'))
34 .map((monthOrders, month) => ({
35 month,
36 revenue: _.sumBy(monthOrders, 'amount')
37 }))
38 .orderBy('month')
39 .value();
40
41// Top 10 users by revenue
42const topUsers = _.chain(enrichedOrders)
43 .groupBy('user_id')
44 .map((userOrders, userId) => ({
45 userId,
46 name: userMap[userId]?.name ?? 'Unknown',
47 revenue: _.sumBy(userOrders, 'amount'),
48 orderCount: userOrders.length
49 }))
50 .orderBy('revenue', 'desc')
51 .take(10)
52 .value();
53
54return { kpis, revenueByMonth, topUsers, enrichedOrders };

Common mistakes when handling Data Transformations in Retool

Why it's a problem: Using nested loops (find inside map) to join two large arrays, causing performance issues

How to avoid: Build a lookup object first: const map = _.keyBy(query2.data, 'id'). Then use map[row.id] inside .map() for O(1) lookup instead of O(n) find.

Why it's a problem: Forgetting to return a value from a reduce accumulator function

How to avoid: Every callback in .reduce() must return the accumulator. A missing 'return acc;' makes the accumulator undefined on the next iteration, causing cascading errors.

Why it's a problem: Trying to call query.trigger() inside a transformer to refresh data

How to avoid: Transformers are read-only and cannot trigger queries. If you need to re-fetch data based on transformation results, use a JS Query with query.trigger() and await.

Why it's a problem: Binding a transformer output to {{ transformer1.data }} instead of {{ transformer1.value }}

How to avoid: Transformers expose their return value as .value (e.g. transformer1.value). Using .data returns undefined. Queries use .data; transformers use .value.

Best practices

  • Build lookup maps (using reduce or _.keyBy) before joining — O(n+m) lookup maps dramatically outperform O(n*m) nested loops on large datasets.
  • Always add null guards at the start of every transformer: 'const data = query1.data || [];' prevents crashes when queries haven't run yet.
  • Use Lodash's _.chain() for complex multi-step transformations — it enables fluent chaining without creating intermediate variables.
  • Return early with a meaningful default value if input data is empty: 'if (data.length === 0) return { total: 0, items: [] };'
  • Keep transformers focused on one logical output — if a transformer is getting long, split it into two transformers that each serve a specific component.
  • Transformers are read-only: they cannot trigger queries, set state, or make async calls. Move any side effects to JS Queries.
  • Use _.orderBy(array, ['field'], ['desc']) over .sort() for multi-field sorting — it is more readable and handles null values better.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I'm building a Retool sales dashboard. I have three SQL queries: query1 returns orders (id, user_id, amount, status, created_at), query2 returns users (id, name, email, department, is_active), and query3 returns products (id, name, category). Write a Retool standalone transformer named 'salesDashboard' that: (1) joins orders with users using a lookup map, (2) computes KPIs (total revenue, order count, average order value), (3) groups revenue by month for a line chart, and (4) identifies the top 10 users by revenue. Use Lodash utilities available as _ and return a single object with all these datasets.

Retool Prompt

Create a transformer called 'usersWithStats' that joins query1.data (users: id, name, email, department) with query2.data (stats: user_id, total_orders, total_revenue) using a _.keyBy lookup map. The output should be the users array with totalOrders, totalRevenue, and isHighValue (true if revenue >= 1000) added to each user object. Handle missing stats with safe defaults. Access the result as {{ usersWithStats.value }}.

Frequently asked questions

How do I join data from two Retool queries in a transformer?

Build a lookup map from one query using _.keyBy(query2.data, 'id') or reduce(), then map over the other query using that map for O(1) lookups: users.map(user => ({ ...user, ...statsMap[user.id] })). This is more efficient than using .find() inside .map() which is O(n*m).

Is Lodash available in Retool transformers?

Yes. Lodash is bundled in Retool and available as the global _ variable in all transformers and JS Queries. You do not need to import it. Use _.groupBy, _.keyBy, _.sumBy, _.orderBy, _.chain, and any other Lodash utilities directly.

Can I use a Retool transformer to aggregate data from more than two queries?

Yes. A standalone transformer can reference any number of queries (query1.data, query2.data, query3.data, etc.). All referenced queries become dependencies and the transformer re-runs whenever any of them return new data. Build lookup maps from each query and join them in a single mapping operation.

Why is my .reduce() returning undefined instead of an aggregated value?

The most common cause is a missing 'return acc;' inside the reduce callback. Every path through the callback must return the accumulator. Also ensure your initial value (the second argument to reduce) matches the expected output shape — if you expect an object, start with {} not 0.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Learning is great. Shipping is faster with help.

Our engineers have built 600+ apps on Retool and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.