Repository: bodo-ai/PyDough

Issue:
Add support for more slicing cases with SUBSTR
Currently, PyDough only allows string slicing in cases where the step is absent (or 1), and the start/stop are both non-negative (or absent), by translating to `SUBSTR`. However, `SUBSTR` has support for negatives, which we should also support. This means we can expand the conversion logic in `convert_slice` to handle the following additional cases:
1. `s[-n:]` -> `SUBSTR(s, -n)`
2. `s[-n:-m]` -> `SUBSTR(s, -n, n-m))`
3. `s[:-n]` -> `SUBSTR(s, 1, LENGTH(s) -n)`

If the dialect does not allow negative indices (like default ANSI), can change it so `LENGTH(s) - n` is always used for the second argument in the first 2 cases. This is viable because currently we require the slicing indices to be integer literals.

These expansions should all be tested, and should be noted in the function list documentation (which currently specifies the restricted semantics). The only restriction on slicing with `s[...]` syntax should now be that:
- The expression being sliced (`s`) must be a PyDough term (rather than a string literal)
- The step (e.g. `c` in `s[a:b:c]` must be 1 or must be absent
- The start/stop/step must all be litearls

Discussion:

Patch:
diff --git a/documentation/functions.md b/documentation/functions.md
index 8f4afc7..49c1af1 100644
--- a/documentation/functions.md
+++ b/documentation/functions.md
@@ -157,12 +157,17 @@ Below are all other operators currently supported in PyDough that use other synt
 
 ### Slicing
 
-A string expression can have a substring extracted with Python string slicing syntax `s[a:b:c]`:
+A string expression can have a substring extracted with Python string slicing syntax `s[a:b:c]`.
+The implementation is based on Python slicing semantics. PyDough supports negative slicing, but currently, it does not support providing step values other than 1.
 
 ```py
 Customers.CALCULATE(
     country_code = phone[:3],
-    name_without_first_char = name[1:]
+    name_without_first_char = name[1:],
+    last_digit = phone[-1:],
+    name_without_start_and_end_char = name[1:-1]
+    phone_without_last_5_chars = phone[:-5]
+    name_second_to_last_char = name[-2:-1]
 )
 ```
 
diff --git a/pydough/pydough_operators/expression_operators/README.md b/pydough/pydough_operators/expression_operators/README.md
index d63abf4..fe99d92 100644
--- a/pydough/pydough_operators/expression_operators/README.md
+++ b/pydough/pydough_operators/expression_operators/README.md
@@ -59,7 +59,7 @@ These are created with a prefix operator syntax instead of called as a function.
 
 These are other PyDough operators that are not necessarily used as functions:
 
-- `SLICE`: operator used for string slicing, with the same semantics as Python string slicing. If `s[a:b:c]` is done, that is translated to `SLICE(s,a,b,c)` in PyDough, and any of `a`/`b`/`c` could be absent. Currently, PyDough does not support negative indices or providing step values other than 1.
+- `SLICE`: operator used for string slicing, with the same semantics as Python string slicing. If `s[a:b:c]` is done, that is translated to `SLICE(s,a,b,c)` in PyDough, and any of `a`/`b`/`c` could be absent. Negative slicing is supported. Currently PyDough does not support providing step values other than 1.
 
 #### Scalar Functions
 
diff --git a/pydough/sqlglot/transform_bindings.py b/pydough/sqlglot/transform_bindings.py
index 3461063..64adf82 100644
--- a/pydough/sqlglot/transform_bindings.py
+++ b/pydough/sqlglot/transform_bindings.py
@@ -509,12 +509,68 @@ def convert_concat(
         return Concat(expressions=inputs)
 
 
+def positive_index(
+    string_expr: SQLGlotExpression, neg_index: int, is_zero_based: bool = False
+) -> SQLGlotExpression:
+    """
+    Gives the SQL Glot expression for converting a
+    negative index to a positive index in 1 or 0 based indexing
+    based on the length of the column.
+
+    Args:
+        `string_expr`: The expression to reference
+        `neg_index`: The negative index in 0 based index to convert to positive
+        `is_zero_based`: Whether the return index is 0-based or 1-based
+
+    Returns:
+        SQLGlot expression corresponding to:
+        `(LENGTH(string_expr) + neg_index + offset)`,
+         where offset is 0, if is_zero_based is True, else 1.
+    """
+    sql_len = sqlglot_expressions.Length(this=string_expr)
+    offset = 0 if is_zero_based else 1
+    return apply_parens(
+        sqlglot_expressions.Add(
+            this=sql_len, expression=sqlglot_expressions.convert(neg_index + offset)
+        )
+    )
+
+
 def convert_slice(
     raw_args: Sequence[RelationalExpression] | None,
     sql_glot_args: Sequence[SQLGlotExpression],
 ) -> SQLGlotExpression:
     """
     Support for generating a `SLICE` expression from a list of arguments.
+    It is expected that len(sql_glot_args) == 4.
+    The first argument is the string to slice.
+    The second argument is the `start` index.
+    The third argument is the `stop` index.
+    The fourth argument is the `step`.
+    Outline of the logic:
+    - Case 1: `(None, None)`
+        - Returns the string as is.
+    - Case 2: `(start, None)`
+        - Positive `start`: Convert to 1-based indexing and slice from `start`.
+        - Negative `start`: Compute `LENGTH(string) + start + 1`; clamp to `1` if less than `1`.
+    - Case 3: `(None, stop)`
+        - Positive `stop`: Slice from position `1` to `stop`.
+        - Negative `stop`: Compute `LENGTH(string) + stop`; clamp to `0` if less than `0` (empty slice).
+    - Case 4: `(start, stop)`
+        - 1. Both `start` & `stop` >= 0:
+            - Convert `start` to 1-based.
+            - Set `length = stop - start`.
+        - 2. `start < 0`, `stop >= 0`:
+            - Convert `start` to 1 based index. If < 1, set to 1.
+            - Compute `length = stop - start` (clamp to 0 if negative).
+        - 3. `start >= 0`, `stop < 0`:
+            - Convert `stop` & `start` to 1 based index.
+            - If `stop` < 1, slice is empty (`length = 0`).
+            - Else, `length = stop - start`.
+        - 4. `start < 0`, `stop < 0`:
+            - Convert `start` & `stop` to 1 based index. If `start` < 1, set to 1.
+            - If `stop` < 1, slice is empty (`length = 0`).
+            - Else, `length = stop - start`.
 
     Args:
         `raw_args`: The operands to `SLICE`, before they were
@@ -523,60 +579,268 @@ def convert_slice(
         converted to SQLGlot expressions.
 
     Returns:
-        The SQLGlot expression matching the functionality of `SUBSTRING`.
+        The SQLGlot expression matching the functionality of Python based string slicing
+        with the caveat that it only supports a step of 1.
     """
     assert len(sql_glot_args) == 4
+    string_expr, start, stop, step = sql_glot_args
 
-    for arg in sql_glot_args[1:]:
-        if not isinstance(arg, (sqlglot_expressions.Literal, sqlglot_expressions.Null)):
-            raise NotImplementedError(
-                "SLICE function currently only supports the slicing arguments being integer literals or absent"
+    start_idx: int | None = None
+    if not isinstance(start, sqlglot_expressions.Null):
+        if isinstance(start, sqlglot_expressions.Literal):
+            try:
+                start_idx = int(start.this)
+            except ValueError:
+                raise ValueError(
+                    "SLICE function currently only supports the start index being integer literal or absent."
+                )
+        else:
+            raise ValueError(
+                "SLICE function currently only supports the start index being integer literal or absent."
             )
 
-    _, start, stop, step = sql_glot_args
-    start_idx: int | None = None
     stop_idx: int | None = None
-    if isinstance(start, sqlglot_expressions.Literal):
-        if int(start.this) < 0:
-            raise NotImplementedError(
-                "SLICE function currently only supports non-negative start indices"
-            )
-        start_idx = int(start.this)
-    if isinstance(stop, sqlglot_expressions.Literal):
-        if int(stop.this) < 0:
-            raise NotImplementedError(
-                "SLICE function currently only supports non-negative stop indices"
+    if not isinstance(stop, sqlglot_expressions.Null):
+        if isinstance(stop, sqlglot_expressions.Literal):
+            try:
+                stop_idx = int(stop.this)
+            except ValueError:
+                raise ValueError(
+                    "SLICE function currently only supports the stop index being integer literal or absent."
+                )
+        else:
+            raise ValueError(
+                "SLICE function currently only supports the stop index being integer literal or absent."
             )
-        stop_idx = int(stop.this)
-    if isinstance(step, sqlglot_expressions.Literal):
-        if int(step.this) != 1:
-            raise NotImplementedError(
-                "SLICE function currently only supports a step of 1"
+
+    step_idx: int | None = None
+    if not isinstance(step, sqlglot_expressions.Null):
+        if isinstance(step, sqlglot_expressions.Literal):
+            try:
+                step_idx = int(step.this)
+                if step_idx != 1:
+                    raise ValueError(
+                        "SLICE function currently only supports the step being integer literal 1 or absent."
+                    )
+            except ValueError:
+                raise ValueError(
+                    "SLICE function currently only supports the step being integer literal 1 or absent."
+                )
+        else:
+            raise ValueError(
+                "SLICE function currently only supports the step being integer literal 1 or absent."
             )
 
+    # SQLGlot expressions for 0 and 1 and empty string
+    sql_zero = sqlglot_expressions.convert(0)
+    sql_one = sqlglot_expressions.convert(1)
+    sql_empty_str = sqlglot_expressions.convert("")
+
     match (start_idx, stop_idx):
         case (None, None):
-            raise sql_glot_args[0]
+            raise string_expr
         case (_, None):
             assert start_idx is not None
-            return sqlglot_expressions.Substring(
-                this=sql_glot_args[0], start=sqlglot_expressions.convert(start_idx + 1)
-            )
+            if start_idx > 0:
+                return sqlglot_expressions.Substring(
+                    this=string_expr,
+                    start=sqlglot_expressions.convert(start_idx + 1),
+                )
+            else:
+                # Calculate the positive index equivalent for the negative index
+                # e.g., for string "hello" and index -2, converts to index 4 (LENGTH("hello") + (-2) + 1)
+                start_idx_glot = positive_index(string_expr, start_idx)
+
+                # Create a SUBSTRING expression with adjusted start position
+                answer = sqlglot_expressions.Substring(
+                    this=string_expr,  # The original string to slice
+                    start=convert_iff_case(
+                        None,
+                        [
+                            # Check if the calculated positive index is less than 1
+                            sqlglot_expressions.LT(
+                                this=start_idx_glot, expression=sql_one
+                            ),
+                            sql_one,  # If true, use index 1 (start from beginning)
+                            start_idx_glot,  # If false, use the calculated positive index
+                        ],
+                    ),
+                )
+                return answer
         case (None, _):
             assert stop_idx is not None
-            return sqlglot_expressions.Substring(
-                this=sql_glot_args[0],
-                start=sqlglot_expressions.convert(1),
-                length=sqlglot_expressions.convert(stop_idx),
-            )
+            if stop_idx > 0:
+                return sqlglot_expressions.Substring(
+                    this=string_expr,
+                    start=sql_one,
+                    length=sqlglot_expressions.convert(stop_idx),
+                )
+            else:
+                # Convert negative stop index to positive index
+                # For example, with string "hello" and stop_idx=-2:
+                # LENGTH("hello") + (-2) = 3 when is_zero_based=True
+                # No +1 adjustment needed since we're using 0-based indexing
+                # to calculate the length, of which the higher bound is exclusive.
+                stop_idx_glot = positive_index(string_expr, stop_idx, True)
+
+                # Create a SUBSTRING expression that starts from beginning
+                return sqlglot_expressions.Substring(
+                    this=string_expr,  # The original string to slice
+                    start=sql_one,  # Always start from position 1
+                    length=convert_iff_case(
+                        None,
+                        [
+                            # Check if the calculated stop position is less than 0
+                            sqlglot_expressions.LT(
+                                this=stop_idx_glot, expression=sql_zero
+                            ),
+                            sql_zero,  # If true, length is 0 (empty string)
+                            stop_idx_glot,  # If false, use index position as length
+                        ],
+                    ),
+                )
         case _:
             assert start_idx is not None
             assert stop_idx is not None
-            return sqlglot_expressions.Substring(
-                this=sql_glot_args[0],
-                start=sqlglot_expressions.convert(start_idx + 1),
-                length=sqlglot_expressions.convert(stop_idx - start_idx),
-            )
+            # Get the positive index if negative
+            if start_idx >= 0 and stop_idx >= 0:
+                if start_idx > stop_idx:
+                    return sql_empty_str
+                return sqlglot_expressions.Substring(
+                    this=string_expr,
+                    start=sqlglot_expressions.convert(start_idx + 1),
+                    length=sqlglot_expressions.convert(stop_idx - start_idx),
+                )
+            if start_idx < 0 and stop_idx >= 0:
+                # Calculate the positive index equivalent for the negative start index
+                # e.g., for string "hello" and start_idx=-2, converts to index 4 (LENGTH("hello") + (-2) + 1)
+                start_idx_glot = positive_index(string_expr, start_idx)
+
+                # Adjust start index to ensure it's not less than 1 (SQL's SUBSTRING is 1-based)
+                start_idx_adjusted_glot = convert_iff_case(
+                    None,
+                    [
+                        sqlglot_expressions.LT(this=start_idx_glot, expression=sql_one),
+                        sql_one,  # If calculated position < 1, use position 1
+                        start_idx_glot,  # Otherwise use calculated position
+                    ],
+                )
+
+                # Convert positive stop_idx to 1-based indexing by adding 1
+                # e.g., for stop_idx=3 (0-based), converts to 4 (1-based)
+                stop_idx_adjusted_glot = sqlglot_expressions.convert(stop_idx + 1)
+
+                # Create the SUBSTRING expression
+                answer = sqlglot_expressions.Substring(
+                    this=string_expr,  # The original string to slice
+                    start=start_idx_adjusted_glot,  # Use adjusted start position
+                    length=convert_iff_case(
+                        None,
+                        [
+                            # Check if the length (stop - start) is negative or zero
+                            sqlglot_expressions.LTE(
+                                this=sqlglot_expressions.Sub(
+                                    this=stop_idx_adjusted_glot,
+                                    expression=start_idx_adjusted_glot,
+                                ),
+                                expression=sql_zero,
+                            ),
+                            sql_empty_str,  # If length ≤ 0, return empty string
+                            # Otherwise calculate actual length
+                            sqlglot_expressions.Sub(
+                                this=stop_idx_adjusted_glot,
+                                expression=start_idx_adjusted_glot,
+                            ),
+                        ],
+                    ),
+                )
+                return answer
+            if start_idx >= 0 and stop_idx < 0:
+                # Convert negative stop index to its positive equivalent
+                # e.g., for string "hello" and stop_idx=-2, converts to index 4 (LENGTH("hello") + (-2) + 1)
+                stop_idx_adjusted_glot = positive_index(string_expr, stop_idx)
+
+                # Convert start index to 1-based indexing (SQL's SUBSTRING is 1-based)
+                # e.g., for start_idx=1 (0-based), converts to 2 (1-based)
+                start_idx_adjusted_glot = sqlglot_expressions.convert(start_idx + 1)
+
+                # Create the SUBSTRING expression
+                answer = sqlglot_expressions.Substring(
+                    this=string_expr,  # The original string to slice
+                    start=start_idx_adjusted_glot,  # Use 1-based start position
+                    length=convert_iff_case(
+                        None,
+                        [
+                            # First check: Is the calculated stop position less than 1?
+                            sqlglot_expressions.LT(
+                                this=stop_idx_adjusted_glot, expression=sql_one
+                            ),
+                            sql_zero,  # If true, length becomes 0 (empty string)
+                            convert_iff_case(
+                                None,
+                                [  # Second check: Is the length negative?
+                                    sqlglot_expressions.LTE(
+                                        this=sqlglot_expressions.Sub(
+                                            this=stop_idx_adjusted_glot,
+                                            expression=start_idx_adjusted_glot,
+                                        ),
+                                        expression=sql_zero,
+                                    ),
+                                    sql_empty_str,  # If length ≤ 0, return empty string
+                                    sqlglot_expressions.Sub(  # Otherwise calculate actual length
+                                        this=stop_idx_adjusted_glot,
+                                        expression=start_idx_adjusted_glot,
+                                    ),
+                                ],
+                            ),
+                        ],
+                    ),
+                )
+                return answer
+            if start_idx < 0 and stop_idx < 0:
+                # Early return if start index is greater than stop index
+                # e.g., "hello"[-2:-4] should return empty string
+                if start_idx >= stop_idx:
+                    return sql_empty_str
+
+                # Convert negative start index to positive equivalent
+                # e.g., for string "hello" and start_idx=-2, converts to index 4 (LENGTH("hello") + (-2) + 1)
+                pos_start_idx_glot = positive_index(string_expr, start_idx)
+
+                # Adjust start index to ensure it's not less than 1 (SQL's SUBSTRING is 1-based)
+                start_idx_adjusted_glot = convert_iff_case(
+                    None,
+                    [
+                        sqlglot_expressions.LT(
+                            this=pos_start_idx_glot, expression=sql_one
+                        ),
+                        sql_one,  # If calculated position < 1, use position 1
+                        pos_start_idx_glot,  # Otherwise use calculated position
+                    ],
+                )
+
+                # Convert negative stop index to positive equivalent
+                stop_idx_adjusted_glot = positive_index(string_expr, stop_idx)
+
+                # Create the SUBSTRING expression
+                return sqlglot_expressions.Substring(
+                    this=string_expr,  # The original string to slice
+                    start=start_idx_adjusted_glot,  # Use adjusted start position
+                    length=convert_iff_case(
+                        None,
+                        [
+                            # Check if the stop position is less than 1
+                            sqlglot_expressions.LT(
+                                this=stop_idx_adjusted_glot, expression=sql_one
+                            ),
+                            sql_zero,  # Length becomes 0 if stop_idx is < 1
+                            sqlglot_expressions.Sub(  # Else calculate length as (stop - start)
+                                this=stop_idx_adjusted_glot,
+                                expression=start_idx_adjusted_glot,
+                            ),
+                        ],
+                    ),
+                )
 
 
 def convert_concat_ws(


Test patch:
diff --git a/tests/bad_pydough_functions.py b/tests/bad_pydough_functions.py
index 21a13c3..d86b005 100644
--- a/tests/bad_pydough_functions.py
+++ b/tests/bad_pydough_functions.py
@@ -3,6 +3,7 @@
 # ruff & mypy should not try to typecheck or verify any of this
 
 import math
+import datetime
 
 
 def bad_bool_1():
@@ -58,23 +59,73 @@ def bad_window_7():
 
 
 def bad_slice_1():
-    # Unsupported slicing: negative stop
-    return Customers.CALCULATE(name[:-1])
+    # Unsupported slicing: skipping
+    return Customers.CALCULATE(name[1:10:2])
 
 
 def bad_slice_2():
-    # Unsupported slicing: negative start
-    return Customers.CALCULATE(name[-5:])
+    # Unsupported slicing: reversed
+    return Customers.CALCULATE(name[::-1])
 
 
 def bad_slice_3():
-    # Unsupported slicing: skipping
-    return Customers.CALCULATE(name[1:10:2])
+    # Unsupported slicing: non-integer start
+    return Customers.CALCULATE(name["invalid":-1:])
 
 
 def bad_slice_4():
-    # Unsupported slicing: reversed
-    return Customers.CALCULATE(name[::-1])
+    # Unsupported slicing: non-integer start
+    return Customers.CALCULATE(name[datetime.datetime.now() : -1 :])
+
+
+def bad_slice_5():
+    # Unsupported slicing: non-integer start
+    return Customers.CALCULATE(name[42.4:10:])
+
+
+def bad_slice_6():
+    # Unsupported slicing: non-integer stop
+    return Customers.CALCULATE(name[1:"invalid":])
+
+
+def bad_slice_7():
+    # Unsupported slicing: non-integer stop
+    return Customers.CALCULATE(name[1 : datetime.datetime.now() :])
+
+
+def bad_slice_8():
+    # Unsupported slicing: non-integer stop
+    return Customers.CALCULATE(name[1:42.4:])
+
+
+def bad_slice_9():
+    # Unsupported slicing: non-integer step
+    return Customers.CALCULATE(name[1:10:"invalid"])
+
+
+def bad_slice_10():
+    # Unsupported slicing: non-integer step
+    return Customers.CALCULATE(name[1 : 10 : datetime.datetime.now()])
+
+
+def bad_slice_11():
+    # Unsupported slicing: non-integer step
+    return Customers.CALCULATE(name[1:10:42.4])
+
+
+def bad_slice_12():
+    # Unsupported slicing: non-integer start
+    return Customers.CALCULATE(name[LENGTH(name) : 10 :])
+
+
+def bad_slice_13():
+    # Unsupported slicing: non-integer step
+    return Customers.CALCULATE(name[1 : LENGTH(name) :])
+
+
+def bad_slice_14():
+    # Unsupported slicing: non-integer step
+    return Customers.CALCULATE(name[1 : 10 : LENGTH(name)])
 
 
 def bad_floor():
diff --git a/tests/simple_pydough_functions.py b/tests/simple_pydough_functions.py
index 4e7add2..47b082a 100644
--- a/tests/simple_pydough_functions.py
+++ b/tests/simple_pydough_functions.py
@@ -827,3 +827,52 @@ def datediff():
         )
         .TOP_K(30, by=years_diff.ASC())
     )
+
+
+def step_slicing():
+    return Customers.CALCULATE(
+        name,
+        neg_none_step=name[-2::1],
+        pos_none_step=name[3::1],
+        none_pos_step=name[:3:1],
+        none_neg_step=name[:-2:1],
+        pos_pos_step=name[2:4:1],
+        pos_neg_step=name[2:-2:1],
+        neg_pos_step=name[-12:2:1],
+        neg_neg_step=name[-4:-2:1],
+        inbetween_chars=name[1:-1:1],
+        empty1=name[2:2:1],
+        empty2=name[-2:-2:1],
+        empty3=name[-2:-4:1],
+        empty4=name[4:2:1],
+        oob1=name[100:200:1],
+        oob2=name[-200:-100:1],
+        oob3=name[100::1],
+        oob4=name[-200::1],
+        oob5=name[:100:1],
+        oob6=name[:-200:1],
+        oob7=name[100:-200:1],
+        oob8=name[-200:100:1],
+        oob9=name[100:-1:1],
+        oob10=name[-100:-1:1],
+        oob11=name[-3:100:1],
+        oob12=name[-3:-100:1],
+        zero1=name[0:0:1],
+        zero2=name[0:1:1],
+        zero3=name[-1:0:1],
+        zero4=name[1:0:1],
+        zero5=name[0:-1:1],
+        zero6=name[0:-20:1],
+        zero7=name[0:100:1],
+        zero8=name[20:0:1],
+        zero9=name[-20:0:1],
+        wo_step1=name[-2:],
+        wo_step2=name[3:],
+        wo_step3=name[:3],
+        wo_step4=name[:-2],
+        wo_step5=name[2:4],
+        wo_step6=name[2:-2],
+        wo_step7=name[-4:2],
+        wo_step8=name[-4:-2],
+        wo_step9=name[2:2],
+    )
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index adb5f5b..91ab4ae 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -11,6 +11,16 @@ from bad_pydough_functions import (
     bad_slice_2,
     bad_slice_3,
     bad_slice_4,
+    bad_slice_5,
+    bad_slice_6,
+    bad_slice_7,
+    bad_slice_8,
+    bad_slice_9,
+    bad_slice_10,
+    bad_slice_11,
+    bad_slice_12,
+    bad_slice_13,
+    bad_slice_14,
 )
 from correlated_pydough_functions import (
     correl_1,
@@ -64,6 +74,7 @@ from simple_pydough_functions import (
     simple_filter_top_five,
     simple_scan,
     simple_scan_top_five,
+    step_slicing,
     triple_partition,
     years_months_days_hours_datediff,
 )
@@ -1267,27 +1278,87 @@ def test_pipeline_e2e(
         pytest.param(
             bad_slice_1,
             None,
-            "SLICE function currently only supports non-negative stop indices",
+            "SLICE function currently only supports the step being integer literal 1 or absent.",
             id="bad_slice_1",
         ),
         pytest.param(
             bad_slice_2,
             None,
-            "SLICE function currently only supports non-negative start indices",
+            "SLICE function currently only supports the step being integer literal 1 or absent.",
             id="bad_slice_2",
         ),
         pytest.param(
             bad_slice_3,
             None,
-            "SLICE function currently only supports a step of 1",
+            "SLICE function currently only supports the start index being integer literal or absent.",
             id="bad_slice_3",
         ),
         pytest.param(
             bad_slice_4,
             None,
-            "SLICE function currently only supports a step of 1",
+            "SLICE function currently only supports the start index being integer literal or absent.",
             id="bad_slice_4",
         ),
+        pytest.param(
+            bad_slice_5,
+            None,
+            "SLICE function currently only supports the start index being integer literal or absent.",
+            id="bad_slice_5",
+        ),
+        pytest.param(
+            bad_slice_6,
+            None,
+            "SLICE function currently only supports the stop index being integer literal or absent.",
+            id="bad_slice_6",
+        ),
+        pytest.param(
+            bad_slice_7,
+            None,
+            "SLICE function currently only supports the stop index being integer literal or absent.",
+            id="bad_slice_7",
+        ),
+        pytest.param(
+            bad_slice_8,
+            None,
+            "SLICE function currently only supports the stop index being integer literal or absent.",
+            id="bad_slice_8",
+        ),
+        pytest.param(
+            bad_slice_9,
+            None,
+            "SLICE function currently only supports the step being integer literal 1 or absent.",
+            id="bad_slice_9",
+        ),
+        pytest.param(
+            bad_slice_10,
+            None,
+            "SLICE function currently only supports the step being integer literal 1 or absent.",
+            id="bad_slice_10",
+        ),
+        pytest.param(
+            bad_slice_11,
+            None,
+            "SLICE function currently only supports the step being integer literal 1 or absent.",
+            id="bad_slice_11",
+        ),
+        pytest.param(
+            bad_slice_12,
+            None,
+            "PyDough objects are currently not supported to be used as indices in Python slices.",
+            id="bad_slice_12",
+        ),
+        pytest.param(
+            bad_slice_13,
+            None,
+            "PyDough objects are currently not supported to be used as indices in Python slices.",
+            id="bad_slice_13",
+        ),
+        pytest.param(
+            bad_slice_14,
+            None,
+            "PyDough objects are currently not supported to be used as indices in Python slices.",
+            id="bad_slice_14",
+        ),
         pytest.param(
             simple_scan,
             [],
@@ -1855,6 +1926,85 @@ def test_pipeline_e2e_errors(
             ),
             id="minutes_seconds_datediff",
         ),
+        pytest.param(
+            (
+                step_slicing,
+                None,
+                "Broker",
+                "step_slicing",
+                lambda: pd.DataFrame(
+                    {
+                        "name": [
+                            "john doe",
+                            "Jane Smith",
+                            "Bob Johnson",
+                            "Samantha Lee",
+                            "Michael Chen",
+                            "Emily Davis",
+                            "David Kim",
+                            "Sarah Nguyen",
+                            "William Garcia",
+                            "Jessica Hernandez",
+                            "Alex Rodriguez",
+                            "Olivia Johnson",
+                            "Ethan Davis",
+                            "Ava Wilson",
+                            "Emma Brown",
+                            "sophia martinez",
+                            "Jacob Taylor",
+                            "Michael Anderson",
+                            "Isabella Thompson",
+                            "Maurice Lee",
+                        ]
+                    }
+                ).assign(
+                    neg_none_step=lambda x: x["name"].str[-2::1],
+                    pos_none_step=lambda x: x["name"].str[3::1],
+                    none_pos_step=lambda x: x["name"].str[:3:1],
+                    none_neg_step=lambda x: x["name"].str[:-2:1],
+                    pos_pos_step=lambda x: x["name"].str[2:4:1],
+                    pos_neg_step=lambda x: x["name"].str[2:-2:1],
+                    neg_pos_step=lambda x: x["name"].str[-12:2:1],
+                    neg_neg_step=lambda x: x["name"].str[-4:-2:1],
+                    inbetween_chars=lambda x: x["name"].str[1:-1:1],
+                    empty1=lambda x: x["name"].str[2:2:1],
+                    empty2=lambda x: x["name"].str[-2:-2:1],
+                    empty3=lambda x: x["name"].str[-2:-4:1],
+                    empty4=lambda x: x["name"].str[4:2:1],
+                    oob1=lambda x: x["name"].str[100:200:1],
+                    oob2=lambda x: x["name"].str[-200:-100:1],
+                    oob3=lambda x: x["name"].str[100::1],
+                    oob4=lambda x: x["name"].str[-200::1],
+                    oob5=lambda x: x["name"].str[:100:1],
+                    oob6=lambda x: x["name"].str[:-200:1],
+                    oob7=lambda x: x["name"].str[100:-200:1],
+                    oob8=lambda x: x["name"].str[-200:100:1],
+                    oob9=lambda x: x["name"].str[100:-1:1],
+                    oob10=lambda x: x["name"].str[-100:-1:1],
+                    oob11=lambda x: x["name"].str[-3:100:1],
+                    oob12=lambda x: x["name"].str[-3:-100:1],
+                    zero1=lambda x: x["name"].str[0:0:1],
+                    zero2=lambda x: x["name"].str[0:1:1],
+                    zero3=lambda x: x["name"].str[-1:0:1],
+                    zero4=lambda x: x["name"].str[1:0:1],
+                    zero5=lambda x: x["name"].str[0:-1:1],
+                    zero6=lambda x: x["name"].str[0:-20:1],
+                    zero7=lambda x: x["name"].str[0:100:1],
+                    zero8=lambda x: x["name"].str[20:0:1],
+                    zero9=lambda x: x["name"].str[-20:0:1],
+                    wo_step1=lambda x: x["name"].str[-2:],
+                    wo_step2=lambda x: x["name"].str[3:],
+                    wo_step3=lambda x: x["name"].str[:3],
+                    wo_step4=lambda x: x["name"].str[:-2],
+                    wo_step5=lambda x: x["name"].str[2:4],
+                    wo_step6=lambda x: x["name"].str[2:-2],
+                    wo_step7=lambda x: x["name"].str[-4:2],
+                    wo_step8=lambda x: x["name"].str[-4:-2],
+                    wo_step9=lambda x: x["name"].str[2:2],
+                ),
+            ),
+            id="step_slicing",
+        ),
     ],
 )
 def custom_defog_test_data(
diff --git a/tests/test_plan_refsols/step_slicing.txt b/tests/test_plan_refsols/step_slicing.txt
new file mode 100644
index 0000000..29a41a9
--- /dev/null
+++ b/tests/test_plan_refsols/step_slicing.txt
@@ -0,0 +1,3 @@
+ROOT(columns=[('name', name), ('neg_none_step', neg_none_step), ('pos_none_step', pos_none_step), ('none_pos_step', none_pos_step), ('none_neg_step', none_neg_step), ('pos_pos_step', pos_pos_step), ('pos_neg_step', pos_neg_step), ('neg_pos_step', neg_pos_step), ('neg_neg_step', neg_neg_step), ('inbetween_chars', inbetween_chars), ('empty1', empty1), ('empty2', empty2), ('empty3', empty3), ('empty4', empty4), ('oob1', oob1), ('oob2', oob2), ('oob3', oob3), ('oob4', oob4), ('oob5', oob5), ('oob6', oob6), ('oob7', oob7), ('oob8', oob8), ('oob9', oob9), ('oob10', oob10), ('oob11', oob11), ('oob12', oob12), ('zero1', zero1), ('zero2', zero2), ('zero3', zero3), ('zero4', zero4), ('zero5', zero5), ('zero6', zero6), ('zero7', zero7), ('zero8', zero8), ('zero9', zero9), ('wo_step1', wo_step1), ('wo_step2', wo_step2), ('wo_step3', wo_step3), ('wo_step4', wo_step4), ('wo_step5', wo_step5), ('wo_step6', wo_step6), ('wo_step7', wo_step7), ('wo_step8', wo_step8), ('wo_step9', wo_step9)], orderings=[])
+ PROJECT(columns={'empty1': SLICE(name, 2:int64, 2:int64, 1:int64), 'empty2': SLICE(name, -2:int64, -2:int64, 1:int64), 'empty3': SLICE(name, -2:int64, -4:int64, 1:int64), 'empty4': SLICE(name, 4:int64, 2:int64, 1:int64), 'inbetween_chars': SLICE(name, 1:int64, -1:int64, 1:int64), 'name': name, 'neg_neg_step': SLICE(name, -4:int64, -2:int64, 1:int64), 'neg_none_step': SLICE(name, -2:int64, None:unknown, 1:int64), 'neg_pos_step': SLICE(name, -12:int64, 2:int64, 1:int64), 'none_neg_step': SLICE(name, None:unknown, -2:int64, 1:int64), 'none_pos_step': SLICE(name, None:unknown, 3:int64, 1:int64), 'oob1': SLICE(name, 100:int64, 200:int64, 1:int64), 'oob10': SLICE(name, -100:int64, -1:int64, 1:int64), 'oob11': SLICE(name, -3:int64, 100:int64, 1:int64), 'oob12': SLICE(name, -3:int64, -100:int64, 1:int64), 'oob2': SLICE(name, -200:int64, -100:int64, 1:int64), 'oob3': SLICE(name, 100:int64, None:unknown, 1:int64), 'oob4': SLICE(name, -200:int64, None:unknown, 1:int64), 'oob5': SLICE(name, None:unknown, 100:int64, 1:int64), 'oob6': SLICE(name, None:unknown, -200:int64, 1:int64), 'oob7': SLICE(name, 100:int64, -200:int64, 1:int64), 'oob8': SLICE(name, -200:int64, 100:int64, 1:int64), 'oob9': SLICE(name, 100:int64, -1:int64, 1:int64), 'pos_neg_step': SLICE(name, 2:int64, -2:int64, 1:int64), 'pos_none_step': SLICE(name, 3:int64, None:unknown, 1:int64), 'pos_pos_step': SLICE(name, 2:int64, 4:int64, 1:int64), 'wo_step1': SLICE(name, -2:int64, None:unknown, None:unknown), 'wo_step2': SLICE(name, 3:int64, None:unknown, None:unknown), 'wo_step3': SLICE(name, None:unknown, 3:int64, None:unknown), 'wo_step4': SLICE(name, None:unknown, -2:int64, None:unknown), 'wo_step5': SLICE(name, 2:int64, 4:int64, None:unknown), 'wo_step6': SLICE(name, 2:int64, -2:int64, None:unknown), 'wo_step7': SLICE(name, -4:int64, 2:int64, None:unknown), 'wo_step8': SLICE(name, -4:int64, -2:int64, None:unknown), 'wo_step9': SLICE(name, 2:int64, 2:int64, None:unknown), 'zero1': SLICE(name, 0:int64, 0:int64, 1:int64), 'zero2': SLICE(name, 0:int64, 1:int64, 1:int64), 'zero3': SLICE(name, -1:int64, 0:int64, 1:int64), 'zero4': SLICE(name, 1:int64, 0:int64, 1:int64), 'zero5': SLICE(name, 0:int64, -1:int64, 1:int64), 'zero6': SLICE(name, 0:int64, -20:int64, 1:int64), 'zero7': SLICE(name, 0:int64, 100:int64, 1:int64), 'zero8': SLICE(name, 20:int64, 0:int64, 1:int64), 'zero9': SLICE(name, -20:int64, 0:int64, 1:int64)})
+  SCAN(table=main.sbCustomer, columns={'name': sbCustName})