From Chaos to Compliance A Smart Audit Logging Strategy for MariaDB

When working with production-grade systems, tracking changes to your data tables becomes crucial. In this post, we’ll walk through how to build a self-maintaining audit log system in MariaDB that captures every INSERT
, UPDATE
, and DELETE
—complete with who did it, when, and what changed.
🎯 Goals of This System
Automatically log
INSERT
,UPDATE
, andDELETE
actions.Record the table name, action type, primary key, and full row data.
Include the current user (optional, via a session variable
@app_user
).Automatically detect and audit new tables.
Automatically remove tracking for deleted tables.
Use predictable trigger names (prefixed with
audit_
) for easy cleanup.
🏗 Step 1: Create the Audit Tables
audit_log
audit_log
CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(255),
action_type ENUM('INSERT', 'UPDATE', 'DELETE'),
primary_key_value VARCHAR(255),
old_data TEXT,
new_data TEXT,
username VARCHAR(255) DEFAULT NULL,
action_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
audit_trigger_tracker
audit_trigger_tracker
CREATE TABLE IF NOT EXISTS audit_trigger_tracker (
table_name VARCHAR(255) PRIMARY KEY,
trigger_created BOOLEAN DEFAULT FALSE,
last_checked TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
🔄 Step 2: Auto-Generate Triggers for New Tables
DELIMITER $$
DROP PROCEDURE IF EXISTS generate_audit_triggers $$
CREATE PROCEDURE generate_audit_triggers()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE tbl_name VARCHAR(255);
DECLARE pk_column VARCHAR(255);
DECLARE old_json TEXT;
DECLARE new_json TEXT;
DECLARE cur CURSOR FOR
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'stalker'
AND table_name NOT IN (SELECT table_name FROM audit_trigger_tracker)
AND table_name NOT IN ('audit_log', 'audit_trigger_tracker');
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO tbl_name;
IF done THEN
LEAVE read_loop;
END IF;
SELECT COLUMN_NAME INTO pk_column
FROM information_schema.columns
WHERE table_schema = 'stalker'
AND table_name = tbl_name
AND COLUMN_KEY = 'PRI'
LIMIT 1;
SELECT GROUP_CONCAT(CONCAT('"', COLUMN_NAME, '", OLD.', COLUMN_NAME) SEPARATOR ', ')
INTO old_json
FROM information_schema.columns
WHERE table_schema = 'stalker' AND table_name = tbl_name;
SELECT GROUP_CONCAT(CONCAT('"', COLUMN_NAME, '", NEW.', COLUMN_NAME) SEPARATOR ', ')
INTO new_json
FROM information_schema.columns
WHERE table_schema = 'stalker' AND table_name = tbl_name;
SET @sql_insert = CONCAT('
CREATE TRIGGER `audit_', tbl_name, '_after_insert`
AFTER INSERT ON `', tbl_name, '`
FOR EACH ROW BEGIN INSERT INTO audit_log (table_name, action_type, primary_key_value, new_data, username) VALUES ("', tbl_name, '", "INSERT", NEW.', pk_column, ',
JSON_OBJECT(', new_json, '), @app_user);
END;');
SET @sql_update = CONCAT('
CREATE TRIGGER `audit_', tbl_name, '_after_update`
AFTER UPDATE ON `', tbl_name, '`
FOR EACH ROW BEGIN INSERT INTO audit_log (table_name, action_type, primary_key_value, old_data, new_data, username) VALUES ("', tbl_name, '", "UPDATE", NEW.', pk_column, ',
JSON_OBJECT(', old_json, '),
JSON_OBJECT(', new_json, '),
@app_user); END;');
SET @sql_delete = CONCAT('
CREATE TRIGGER `audit_', tbl_name, '_after_delete`
AFTER DELETE ON `', tbl_name, '`
FOR EACH ROW BEGIN INSERT INTO audit_log (table_name, action_type, primary_key_value, old_data, username) VALUES ("', tbl_name, '", "DELETE", OLD.', pk_column, ',
JSON_OBJECT(', old_json, '), @app_user);
END;');
PREPARE stmt FROM @sql_insert; EXECUTE stmt; DEALLOCATE PREPARE stmt;
PREPARE stmt FROM @sql_update; EXECUTE stmt; DEALLOCATE PREPARE stmt;
PREPARE stmt FROM @sql_delete; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT INTO audit_trigger_tracker (table_name, trigger_created)
VALUES (tbl_name, TRUE);
END LOOP;
CLOSE cur;
END $$
DELIMITER ;
CALL generate_audit_triggers();
🧼 Step 3: Clean Up Dropped Tables (Triggers + Tracker)
DELIMITER $$
DROP PROCEDURE IF EXISTS drop_missing_table_audit_triggers $$
CREATE PROCEDURE drop_missing_table_audit_triggers()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE tbl VARCHAR(255);
DECLARE cur CURSOR FOR
SELECT table_name
FROM audit_trigger_tracker
WHERE table_name NOT IN (
SELECT table_name FROM information_schema.tables WHERE table_schema = 'stalker'
);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
loop_triggers: LOOP
FETCH cur INTO tbl;
IF done THEN
LEAVE loop_triggers;
END IF;
SET @sql1 = CONCAT('DROP TRIGGER IF EXISTS `audit_', tbl, '_after_insert`;');
SET @sql2 = CONCAT('DROP TRIGGER IF EXISTS `audit_', tbl, '_after_update`;');
SET @sql3 = CONCAT('DROP TRIGGER IF EXISTS `audit_', tbl, '_after_delete`;');
PREPARE stmt1 FROM @sql1; EXECUTE stmt1; DEALLOCATE PREPARE stmt1;
PREPARE stmt2 FROM @sql2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2;
PREPARE stmt3 FROM @sql3; EXECUTE stmt3; DEALLOCATE PREPARE stmt3;
DELETE FROM audit_trigger_tracker WHERE table_name = tbl;
END LOOP;
CLOSE cur;
END $$
DELIMITER ;
Cron
CALL drop_missing_table_audit_triggers();
CALL generate_audit_triggers();
🧨 Nuking the Entire Audit Setup
To completely remove the audit system—including all triggers and supporting tables—use:
DELIMITER $$
DROP PROCEDURE IF EXISTS drop_all_audit_triggers $$
CREATE PROCEDURE drop_all_audit_triggers()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE trg_name VARCHAR(255);
DECLARE cur CURSOR FOR
SELECT TRIGGER_NAME
FROM information_schema.triggers
WHERE TRIGGER_SCHEMA = DATABASE()
AND TRIGGER_NAME LIKE 'audit\\_%';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
trg_loop: LOOP
FETCH cur INTO trg_name;
IF done THEN
LEAVE trg_loop;
END IF;
SET @sql = CONCAT('DROP TRIGGER IF EXISTS `', trg_name, '`');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
-- 🔥 ExecuteCALL drop_all_audit_triggers();
-- 🧹 Clean upDROP PROCEDURE drop_all_audit_triggers;
drop procedure drop_missing_table_audit_triggers;
drop procedure generate_audit_triggers;
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS audit_trigger_tracker;
🤖 Automate With Cron
Create a bash script like this:
#!/bin/bash
mysql -u root -p"yourpassword" -h 127.0.0.1 -P 3306 -D stalker -e "
CALL drop_missing_table_audit_triggers();
CALL generate_audit_triggers();"
Then schedule it:
*/5 * * * * /path/to/audit_trigger_update.sh
✅ Conclusion
You now have a fully autonomous audit logging system:
Self-healing on schema changes
Row-level forensic history
Optional user tracking with
@app_user
Clean rollback mechanism
Last updated