# Inverse kinematics for a two-link arm, with Python code

> Derive and code the inverse kinematics of a two-link robot arm: elbow-up and elbow-down solutions, reachability, joint limits and a numerical fallback.

Canonical: https://roboticsengineering.in/inverse-kinematics-2-link-arm-python/ | Published: 2026-10-28 | Updated: 2026-10-28 | Author: Jignesh Panchal

**Forward kinematics tells you where the hand is. Inverse kinematics tells you what angles put it there.** For a two-link planar arm the maths is short enough to do by hand and useful enough to run on a microcontroller.

This tutorial derives the inverse kinematics for a two-link arm, gives working Python, and then covers the parts that trip people up: elbow-up and elbow-down solutions, reachability, joint limits, singularities and how the same idea scales to a six-axis arm with a library.

## The arm we are solving

Two links of length L1 and L2, joined by a shoulder and an elbow, moving in a plane. The shoulder sits at the origin. The end point, sometimes called the wrist centre or tool point, is at coordinates x and y.

| Symbol | Meaning |
| --- | --- |
| L1 | Length of the upper link |
| L2 | Length of the forearm |
| θ1 | Shoulder angle, measured from the x axis |
| θ2 | Elbow angle, relative to the upper link |
| x, y | Target position of the tool point |

## Forward kinematics first

It helps to write the easy direction before the hard one. If you know the angles, the position follows directly:

```
x = L1 * cos(theta1) + L2 * cos(theta1 + theta2)
y = L1 * sin(theta1) + L2 * sin(theta1 + theta2)
```

Every inverse kinematics check you write should end by feeding the answer back through these two lines. If the result does not land on the target, the inverse solution is wrong, no matter how plausible the angles look.

## Deriving the two-link solution

Square and add the forward equations. The cross terms simplify using the identity for the cosine of a difference, and the shoulder angle drops out:

```
x^2 + y^2 = L1^2 + L2^2 + 2 * L1 * L2 * cos(theta2)

cos(theta2) = (x^2 + y^2 - L1^2 - L2^2) / (2 * L1 * L2)
```

That gives the elbow angle. There are two solutions because the cosine is symmetric: one with the elbow above the line to the target and one below.

```
theta2 = +acos(c2)   # elbow-down solution
theta2 = -acos(c2)   # elbow-up solution
```

With the elbow fixed, the shoulder angle comes from a difference of two arctangents. Using atan2 rather than atan matters, because atan2 handles all four quadrants and the case where x is zero.

```
theta1 = atan2(y, x) - atan2(L2 * sin(theta2), L1 + L2 * cos(theta2))
```

## Working Python code

This function returns both solutions, rejects targets outside the workspace, and verifies itself with forward kinematics. It uses degrees at the boundary because servo commands are usually in degrees, but all the maths inside is in radians.

```
import math

def ik2(x, y, L1, L2):
    r2 = x * x + y * y
    c2 = (r2 - L1 * L1 - L2 * L2) / (2 * L1 * L2)

    if c2 > 1.0 + 1e-9 or c2 < -1.0 - 1e-9:
        return None  # target outside the workspace

    c2 = max(-1.0, min(1.0, c2))
    out = []
    for sign in (1.0, -1.0):
        t2 = sign * math.acos(c2)
        t1 = math.atan2(y, x) - math.atan2(
            L2 * math.sin(t2), L1 + L2 * math.cos(t2)
        )
        out.append((t1, t2))
    return out

def fk2(t1, t2, L1, L2):
    x = L1 * math.cos(t1) + L2 * math.cos(t1 + t2)
    y = L1 * math.sin(t1) + L2 * math.sin(t1 + t2)
    return x, y

for t1, t2 in ik2(180, 120, 150, 120):
    x, y = fk2(t1, t2, 150, 120)
    print(round(math.degrees(t1), 2), round(math.degrees(t2), 2),
          round(x, 2), round(y, 2))
```

For a target of 180 mm across and 120 mm up with links of 150 mm and 120 mm, both solutions reproduce the target to within rounding. That is the test to keep in your own code.

## Reachability and the workspace boundary

The arm can only reach points between two circles: an outer circle at L1 + L2 and an inner circle at the absolute difference |L1 − L2|. Outside the outer circle the target is too far. Inside the inner circle it is too close, which happens when one link is much longer than the other.

- At exactly L1 + L2 the arm is fully extended and the elbow is straight. This is a singularity.
- At exactly |L1 − L2| the arm is folded back on itself. This is also a singularity.
- Near either boundary, small changes in target position require large changes in joint angle.
- Servo resolution and link tolerance eat into the theoretical workspace, so plan to use 90 percent of it.

## Choosing between the two solutions

Both solutions reach the same point; they differ in how the arm gets there. Choose by the task, not by preference.

| Consideration | Favours elbow-up | Favours elbow-down |
| --- | --- | --- |
| Obstacle below the arm | Yes | No |
| Obstacle above the arm | No | Yes |
| Reaching over a wall | Yes | Usually no |
| Gravity load on shoulder | Often lower | Often higher |
| Servo travel limits | Check | Check |

In practice you compute both, discard any that violate joint limits, and pick the survivor that is farthest from its limits. If both survive, pick the one closest to the current pose so the arm does not jump between configurations between waypoints.

## Joint limits change everything

A mathematical solution is not a usable solution. Hobby servos typically give 180 degrees of travel, and a shoulder mounted in a bracket might only have 120 usable degrees. Apply the limits after the maths, and treat a target with no valid solution as unreachable rather than clamping the angle.

```
def within(t1, t2, lim1, lim2):
    return (lim1[0] <= t1 <= lim1[1]) and (lim2[0] <= t2 <= lim2[1])

solutions = [s for s in ik2(x, y, L1, L2) or [] if within(*s, lim1, lim2)]
```

Clamping a joint to its limit produces an arm that points somewhere else entirely while the firmware believes it succeeded. That failure is worse than a clean “unreachable” error, because it moves the arm into a collision before anyone notices.

## From model to real servo angles

The model measures the shoulder from the x axis and the elbow from the upper link. Your servo horn does not. You need three calibrations:

- Zero offset: the servo command that makes the link point along the model’s zero direction.
- Direction: whether increasing command increases or decreases the model angle.
- Scale: the pulse width per degree, which varies between servo models and clones.

Calibrate by commanding two known angles, measuring the resulting link direction with a protractor or a printed angle gauge, and solving for offset and scale. Do it once per joint and store the numbers in firmware, not in a comment.

## Where the two-link model stops being enough

Add a third link in the same plane and the arm becomes redundant: infinitely many joint combinations reach the same point. Add a wrist and the tool orientation enters the problem. Add a sixth axis and the closed-form solution becomes long and error-prone.

At that point, use a library. MoveIt 2 with KDL or TRAC-IK solves inverse kinematics for a URDF model, handles joint limits and returns multiple solutions. For a four-axis SCARA arm there is still a clean closed form, which is one reason SCARA robots remain popular for pick-and-place.

| Arm | Practical approach |
| --- | --- |
| 2-link planar | Closed form, as above |
| 3-link planar | Closed form with a redundancy choice, or numerical |
| SCARA, 4 axes | Closed form, well documented |
| 6-axis industrial arm | Library such as MoveIt, TRAC-IK or the vendor’s own solver |
| Mobile manipulator | Whole-body solver or a stack of smaller solvers |

## A numerical fallback in ten lines

When no closed form is available, damped least squares iterates towards the target. It is slower and needs a good starting pose, but it works for any chain you can differentiate or approximate numerically.

```
def ik_numeric(target, q, fk, jac, iters=200, tol=1e-4, damping=1e-3):
    import numpy as np
    target = np.array(target, dtype=float)
    for _ in range(iters):
        err = target - np.array(fk(q))
        if np.linalg.norm(err) < tol:
            break
        J = np.array(jac(q))
        dq = J.T @ np.linalg.solve(J @ J.T + damping * np.eye(2), err)
        q = q + dq
    return q, np.linalg.norm(err)
```

The damping term keeps the step sane near singularities, where the Jacobian becomes ill-conditioned. It trades a little accuracy for stability, which is the right trade for a real arm.

## Testing before you move hardware

- Round-trip test: random angles, forward, inverse, compare.
- Boundary test: points just inside and just outside the workspace.
- Limit test: targets that require an impossible elbow angle.
- Continuity test: a straight-line path sampled every millimetre, checking for joint jumps.
- Speed test: the largest joint step between adjacent path points, in degrees per millisecond.

Simulate the path in Python or Gazebo before running it on the arm. Watching joint angles for a jump is far cheaper than replacing a stripped servo gear.

Related: [robot arm kinematics basics](https://roboticsengineering.in/robot-arm-kinematics-basics/), [pick and place arm with Arduino](https://roboticsengineering.in/pick-and-place-robot-arm-arduino/), [robot arm CAD guide](https://roboticsengineering.in/robot-arm-design-cad-guide/), [MoveIt 2 tutorial](https://roboticsengineering.in/moveit2-tutorial-ros2/), [servo torque calculator](https://roboticsengineering.in/servo-torque-calculator/).

**What is the difference between forward and inverse kinematics?**

Forward kinematics takes joint angles and computes the position of the tool. Inverse kinematics takes a desired tool position and computes the joint angles. Forward always has one answer; inverse often has several or none.

**Why are there two solutions for a two-link arm?**

The elbow can bend one way or the other and still reach the same point. These are called elbow-up and elbow-down. Both are mathematically valid, and you choose between them using obstacles, joint limits and continuity.

**What happens if the target is out of reach?**

The cosine term falls outside the range from minus one to one, and there is no real solution. Report the target as unreachable instead of clamping joint angles, because clamping moves the arm somewhere you did not intend.

**Why does my arm move violently near full extension?**

Near a singularity, small changes in position require large changes in joint angle. Limit the workspace, reduce speed near the boundary, or use damped least squares so the motion stays bounded.

**Do I need inverse kinematics for a six-axis robot?**

You need it, but you should not write it yourself. Use MoveIt 2 with KDL or TRAC-IK, or the robot vendor's solver, and validate the returned solutions against joint limits before executing.

## Sources and further reading

- [ROS 2 documentation](https://docs.ros.org/) — robot modelling and kinematics packages.
- [MoveIt 2](https://moveit.picknik.ai/) — inverse kinematics solvers and planning.
- [MathWorks inverse kinematics](https://www.mathworks.com/help/robotics/ug/inverse-kinematics.html) — analytical and numerical approaches.
