MySQL AFTER DELETE MySQL · Triggers · AFTER DELETE

MySQL AFTER DELETE Trigger

Master MySQL AFTER DELETE triggers — notification logging with proper CONCAT spacing, decrementing summary counts to keep aggregates in sync, manual cleanup of related data when foreign-key cascades aren't an option, and best practices for symmetric INSERT/DELETE trigger pairs.

An AFTER DELETE trigger fires after a row has been removed from the table. The OLD pseudo-row still has the deleted row's data — read-only — so you can see exactly what was removed and react.

The most common uses:

  • Notification logging — record the deletion in a notification or summary table
  • Cleanup of related data in tables that don't have FK ON DELETE CASCADE
  • Decrement summary counts — keep aggregate tables in sync
  • Cascade complex business actions beyond what FK cascades cover
Use BEFORE DELETE for…Use AFTER DELETE for…
Archiving the row to a "deleted" tableLogging notifications about the deletion
Blocking the deletion via SIGNALDecrementing summary counts
Verifying preconditionsCascading cleanup that depends on the deletion having succeeded
💡 For archival, BEFORE DELETE is the standard choice because it executes whether or not the actual DELETE eventually fails (e.g. due to a foreign-key violation downstream). For notification or count adjustment, AFTER DELETE is correct because it only fires when the deletion truly succeeded.
DELIMITER $$ CREATE TRIGGER trigger_name AFTER DELETE ON table_name FOR EACH ROW BEGIN -- read OLD.column to react to the deletion END$$ DELIMITER ;
ReferenceAvailable?
OLD.columnYes (read-only) — the row that was just deleted
NEW.columnNo — there's no new version of a deleted row
MySQL — Setup
DROP TABLE IF EXISTS order_logs;
DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
    order_id    INT PRIMARY KEY,
    customer_id INT,
    order_date  DATE
);

CREATE TABLE order_logs (
    log_id       INT AUTO_INCREMENT PRIMARY KEY,
    order_id     INT,
    log_message  VARCHAR(255),
    log_date     TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deleted_by   VARCHAR(100)
);

INSERT INTO orders (order_id, customer_id, order_date) VALUES
    (1, 1001, '2024-04-10'),
    (2, 1002, '2024-04-12');
Example 1 — Log Each Order Deletion

Trigger that records every order deletion in order_logs. Note the careful use of CONCAT_WS with a space separator — produces clean output even if you change the message format later:

MySQL — Deletion log trigger
DROP TRIGGER IF EXISTS after_delete_order_trigger;

DELIMITER $$

CREATE TRIGGER after_delete_order_trigger
    AFTER DELETE ON orders
    FOR EACH ROW
BEGIN
    DECLARE log_msg VARCHAR(255);

    SET log_msg = CONCAT('Order ', OLD.order_id, ' deleted (customer ',
                         OLD.customer_id, ')');

    INSERT INTO order_logs (order_id, log_message, deleted_by)
    VALUES (OLD.order_id, log_msg, CURRENT_USER());
END$$

DELIMITER ;

DELETE FROM orders WHERE order_id = 1;

SELECT * FROM orders;
SELECT * FROM order_logs;
OUTPUT
mysql — deletion logged
mysql>DELETE FROM orders WHERE order_id = 1;
Query OK, 1 row affected (0.01 sec)
mysql>SELECT * FROM orders;
+----------+-------------+------------+
| order_id | customer_id | order_date |
+----------+-------------+------------+
| 2 | 1002 | 2024-04-12 |
+----------+-------------+------------+
1 row in set (0.00 sec)
mysql>SELECT * FROM order_logs;
+--------+----------+------------------------------------+---------------------+----------------+
| log_id | order_id | log_message | log_date | deleted_by |
+--------+----------+------------------------------------+---------------------+----------------+
| 1 | 1 | Order 1 deleted (customer 1001) | 2026-04-22 15:02:11 | root@localhost |
+--------+----------+------------------------------------+---------------------+----------------+
1 row in set (0.00 sec)
⚠️ The original tutorial had CONCAT('Order ', OLD.order_id, 'deleted') — note the missing space. That produces "Order 1deleted", which looks broken. Always include explicit spaces in your CONCAT arguments, or use CONCAT_WS(' ', ...) which inserts the separator between arguments automatically.
Example 2 — Decrement a Count Table

If you maintained a count summary in an AFTER INSERT trigger (see the AFTER INSERT page), you almost certainly need the matching AFTER DELETE trigger to keep the counts honest:

MySQL — Decrement on delete
CREATE TABLE IF NOT EXISTS customer_order_counts (
    customer_id   INT PRIMARY KEY,
    order_count   INT NOT NULL DEFAULT 0
);

DROP TRIGGER IF EXISTS after_order_delete;

DELIMITER $$

CREATE TRIGGER after_order_delete
    AFTER DELETE ON orders
    FOR EACH ROW
BEGIN
    UPDATE customer_order_counts
    SET    order_count = GREATEST(order_count - 1, 0)
    WHERE  customer_id = OLD.customer_id;
END$$

DELIMITER ;

The GREATEST(... - 1, 0) protects against the count going negative if the summary table somehow drifts.

Example 3 — Cleanup of Related Data Without Foreign Keys

If you can't or don't want to use foreign-key cascades, an AFTER DELETE trigger can clean up related rows manually:

MySQL — Manual cascade
CREATE TABLE IF NOT EXISTS order_items (
    item_id   INT AUTO_INCREMENT PRIMARY KEY,
    order_id  INT,
    product   VARCHAR(100),
    quantity  INT
);

DROP TRIGGER IF EXISTS cleanup_order_items;

DELIMITER $$

CREATE TRIGGER cleanup_order_items
    AFTER DELETE ON orders
    FOR EACH ROW
BEGIN
    DELETE FROM order_items WHERE order_id = OLD.order_id;
END$$

DELIMITER ;
💡 Prefer foreign-key ON DELETE CASCADE when possible. The trigger above duplicates what FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE does for free. The trigger version is appropriate only when the schema lacks the FK relationship, when cleanup logic is more complex than a simple cascade, or when working with non-transactional storage engines that don't enforce FKs.
  1. Use DROP TRIGGER IF EXISTS at the top of every deployment script.
  2. Pair AFTER DELETE with AFTER INSERT for symmetric maintenance — every count incremented by an INSERT trigger should be decremented by a DELETE trigger, otherwise summaries drift.
  3. Watch your CONCAT spaces. A missing space produces "Order 1deleted" instead of "Order 1 deleted" — easy mistake. Use CONCAT_WS(' ', ...) for cleaner output.
  4. Capture metadata — timestamp and CURRENT_USER() in audit/notification logs.
  5. Prefer FK cascades over trigger-based manual cascades when possible.
  6. Remember TRUNCATE bypasses triggers. If your AFTER DELETE trigger does important cleanup, document or programmatically prevent TRUNCATE on that table.
  7. Don't DELETE from the same table the trigger fires on — MySQL forbids it.
  8. For per-row work that scales with bulk DELETEs, measure performance before committing to a trigger-based approach.
  • AFTER DELETE triggers fire after a row has been removed. Only OLD is available, read-only.
  • Common uses: notification logging, summary count maintenance, cleanup of related data when FK cascades aren't an option.
  • Pair with AFTER INSERT triggers for symmetric maintenance — counts and other aggregates need both.
  • Prefer foreign-key ON DELETE CASCADE over trigger-based manual cleanup when the schema allows it.
  • TRUNCATE bypasses DELETE triggers — beware if your trigger does critical work.
  • Watch CONCAT spacing in log messages — missing spaces produce "Order 1deleted"-style typos.