diff --git a/cmdlang/ast.go b/cmdlang/ast.go
index e611d76..9dcfba8 100644
--- a/cmdlang/ast.go
+++ b/cmdlang/ast.go
@@ -29,7 +29,8 @@ type astListOrHash struct {
 }
 
 type astBlock struct {
-	Statements []*astStatements `parser:"LC NL? @@ NL? RC"`
+	Names      []string         `parser:"LC NL? (PIPE @Ident+ PIPE NL?)?"`
+	Statements []*astStatements `parser:"@@ NL? RC"`
 }
 
 type astCmdArg struct {
diff --git a/cmdlang/builtins.go b/cmdlang/builtins.go
index 2918dac..36baf44 100644
--- a/cmdlang/builtins.go
+++ b/cmdlang/builtins.go
@@ -123,7 +123,7 @@ func ifBuiltin(ctx context.Context, args macroArgs) (object, error) {
 	}
 
 	if guard, err := args.evalArg(ctx, 0); err == nil && isTruthy(guard) {
-		return args.evalBlock(ctx, 1)
+		return args.evalBlock(ctx, 1, nil, false)
 	} else if err != nil {
 		return nil, err
 	}
@@ -137,7 +137,7 @@ func ifBuiltin(ctx context.Context, args macroArgs) (object, error) {
 		}
 
 		if guard, err := args.evalArg(ctx, 0); err == nil && isTruthy(guard) {
-			return args.evalBlock(ctx, 1)
+			return args.evalBlock(ctx, 1, nil, false)
 		} else if err != nil {
 			return nil, err
 		}
@@ -146,7 +146,7 @@ func ifBuiltin(ctx context.Context, args macroArgs) (object, error) {
 	}
 
 	if args.identIs(ctx, 0, "else") && args.nargs() > 1 {
-		return args.evalBlock(ctx, 1)
+		return args.evalBlock(ctx, 1, nil, false)
 	} else if args.nargs() == 0 {
 		// no elif or else
 		return nil, nil
@@ -154,3 +154,36 @@ func ifBuiltin(ctx context.Context, args macroArgs) (object, error) {
 
 	return nil, errors.New("malformed if-elif-else")
 }
+
+func foreachBuiltin(ctx context.Context, args macroArgs) (object, error) {
+	if args.nargs() < 2 {
+		return nil, errors.New("need at least 2 arguments")
+	}
+
+	items, err := args.evalArg(ctx, 0)
+	if err != nil {
+		return nil, err
+	}
+
+	var last object
+
+	switch t := items.(type) {
+	case listObject:
+		for _, v := range t {
+			last, err = args.evalBlock(ctx, 1, []object{v}, true) // TO INCLUDE: the index
+			if err != nil {
+				return nil, err
+			}
+		}
+	case hashObject:
+		for k, v := range t {
+			last, err = args.evalBlock(ctx, 1, []object{strObject(k), v}, true)
+			if err != nil {
+				return nil, err
+			}
+		}
+		// TODO: streams
+	}
+
+	return last, nil
+}
diff --git a/cmdlang/env.go b/cmdlang/env.go
index 5e35fd5..165c15b 100644
--- a/cmdlang/env.go
+++ b/cmdlang/env.go
@@ -7,6 +7,10 @@ type evalCtx struct {
 	vars     map[string]object
 }
 
+func (ec *evalCtx) fork() *evalCtx {
+	return &evalCtx{parent: ec}
+}
+
 func (ec *evalCtx) addCmd(name string, inv invokable) {
 	if ec.commands == nil {
 		ec.commands = make(map[string]invokable)
diff --git a/cmdlang/inst.go b/cmdlang/inst.go
index a1646c0..1703289 100644
--- a/cmdlang/inst.go
+++ b/cmdlang/inst.go
@@ -32,6 +32,7 @@ func New(opts ...InstOption) *Inst {
 	rootEC.addCmd("cat", invokableFunc(catBuiltin))
 
 	rootEC.addMacro("if", macroFunc(ifBuiltin))
+	rootEC.addMacro("foreach", macroFunc(foreachBuiltin))
 
 	//rootEC.addCmd("testTimebomb", invokableStreamFunc(errorTestBuiltin))
 
diff --git a/cmdlang/objs.go b/cmdlang/objs.go
index 9bba180..b08ed21 100644
--- a/cmdlang/objs.go
+++ b/cmdlang/objs.go
@@ -123,7 +123,7 @@ func (ma macroArgs) evalArg(ctx context.Context, n int) (object, error) {
 	return ma.eval.evalArg(ctx, ma.ec, ma.ast.Args[ma.argShift+n])
 }
 
-func (ma macroArgs) evalBlock(ctx context.Context, n int) (object, error) {
+func (ma macroArgs) evalBlock(ctx context.Context, n int, args []object, pushScope bool) (object, error) {
 	obj, err := ma.evalArg(ctx, n)
 	if err != nil {
 		return nil, err
@@ -134,7 +134,17 @@ func (ma macroArgs) evalBlock(ctx context.Context, n int) (object, error) {
 		return nil, errors.New("not a block object")
 	}
 
-	return ma.eval.evalBlock(ctx, ma.ec, block.block)
+	ec := ma.ec
+	if pushScope {
+		ec = ec.fork()
+	}
+	for i, n := range block.block.Names {
+		if i < len(args) {
+			ec.setVar(n, args[i])
+		}
+	}
+
+	return ma.eval.evalBlock(ctx, ec, block.block)
 }
 
 type invocationArgs struct {
diff --git a/cmdlang/testbuiltins_test.go b/cmdlang/testbuiltins_test.go
index 8103f33..59c14e3 100644
--- a/cmdlang/testbuiltins_test.go
+++ b/cmdlang/testbuiltins_test.go
@@ -172,3 +172,31 @@ func TestBuiltins_If(t *testing.T) {
 		})
 	}
 }
+
+func TestBuiltins_ForEach(t *testing.T) {
+	tests := []struct {
+		desc string
+		expr string
+		want string
+	}{
+		{desc: "iterate over list", expr: `
+			foreach ["1" "2" "3"] { |v|
+				echo $v
+			}`, want: "1\n2\n3\n(nil)\n"},
+		{desc: "iterate over map", expr: `
+			foreach [a:"1"] { |k v| echo $k "=" $v }`, want: "a=1\n(nil)\n"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.desc, func(t *testing.T) {
+			ctx := context.Background()
+			outW := bytes.NewBuffer(nil)
+
+			inst := New(WithOut(outW), WithTestBuiltin())
+			err := inst.EvalAndDisplay(ctx, tt.expr)
+
+			assert.NoError(t, err)
+			assert.Equal(t, tt.want, outW.String())
+		})
+	}
+}