Overview
Consider a practical scenario where a company launches a new product and needs to perform customer follow-ups with users who placed orders. The业务 team requires specific user information (nickname, gender, phone, email) combined with order details (order ID, purchase timestamp, order amount) to facilitate outreach efforts.
This guide demonstrates how to build a complete pipeline that queries MySQL and writes the results directly to a Feishu bitable for collaborative team access.
Pipeline Architecture
The data synchronization pipeline consists of five distinct phases:
- Phase 1: Target Analysis - Already defined in the scenario above
- Phase 2: Table Creation - Create the bitable structure via API
- Phase 3: Data Extraction - Connect to MySQL and retrieve query results
- Phase 4: Data Transformation - Convert query results to Feishu batch create API format
- Phase 5: Data Insertion - Write the formatted data to the bitable
The following sections elaborate on each phase starting from Phase 2.
Creating the Bitable Structure
Before creating the table, field types must be determined based on bitable capabilities.
The first column typically serves as a unique identifier. Since each user places only one order, the user ID field can serve this purpose as it maintains a one-to-one relationship with order records. Alternatively, an auto-incrementing ID column could be added as the primary key.
Text fields like nickname, phone, and order ID offer basic filtering (exact and pattern matching). For more advanced filtering capabiilties, consider converting to single-select fields where applicable. For example, gender and city fields work well as single-select types.
Important Constraint: Single-select fields are limited to 5,000 options maximum. Exceeding this limit requires using multi-line text fields instead.
The final field configuration is summarized below:
| Field Name | Field Type | type | ui_type | API Data Format |
|---|---|---|---|---|
| UserID | Auto-increment | 2 | Number | Integer |
| Nickname | Multi-line Text | 1 | Text | String |
| Gender | Single Select | 3 | SingleSelect | String |
| Phone | Phone | 13 | Phone | String |
| City | Single Select | 3 | SingleSelect | String |
| OrderID | Multi-line Text | 1 | Text | String |
| PurchaseTimestamp | DateTime | 5 | DateTime | Integer (epoch milliseconds) |
| OrderAmount | Number | 2 | Number | Float |
The API request body structuer includes property configurations:
- UserID uses
{"formatter": "0"}to display as integer - OrderAmount uses
{"formatter": "0.00"}for two decimal places - PurchaseTimestamp uses
{"date_formatter": "yyyy/MM/dd HH:mm", "auto_fill": false}for specific format without auto-population
{
"table": {
"name": "Product Customer List",
"default_view_name": "All Records View",
"fields": [
{"field_name": "UserID", "type": 2, "ui_type": "Number", "property": {"formatter": "0"}},
{"field_name": "Nickname", "type": 1, "ui_type": "Text"},
{"field_name": "Gender", "type": 3, "ui_type": "SingleSelect"},
{"field_name": "Phone", "type": 13, "ui_type": "Phone"},
{"field_name": "City", "type": 3, "ui_type": "SingleSelect"},
{"field_name": "OrderID", "type": 1, "ui_type": "Text"},
{"field_name": "PurchaseTimestamp", "type": 5, "ui_type": "DateTime", "property": {"date_formatter": "yyyy/MM/dd HH:mm", "auto_fill": false}},
{"field_name": "OrderAmount", "type": 2, "ui_type": "Number", "property": {"formatter": "0.00"}}
]
}
}
Execute the table creation via API. Note that Python requires boolean values as True/False rather than true/false:
import requests
import json
def create_bitable(app_token, table_config, access_token):
endpoint = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
}
response = requests.post(endpoint, headers=headers, json=table_config)
result = response.json()
if result.get('code') == 0:
table_id = result['data']['table_id']
print(f"Table created successfully: {table_config['table']['name']} (ID: {table_id})")
return table_id
else:
raise RuntimeError(f"Table creation failed: {result.get('msg')}")
def fetch_tenant_token(app_id, app_secret):
auth_url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
payload = {
"app_id": app_id,
"app_secret": app_secret
}
response = requests.post(auth_url, json=payload)
token = response.json().get('tenant_access_token')
print(f"Authentication successful")
return token
def main():
app_id = 'APP_ID_VALUE'
app_secret = 'APP_SECRET_VALUE'
app_token = 'APP_TOKEN_VALUE'
access_token = fetch_tenant_token(app_id, app_secret)
table_config = {
"table": {
"name": "Product Customer List",
"default_view_name": "All Records View",
"fields": [
{"field_name": "UserID", "type": 2, "ui_type": "Number", "property": {"formatter": "0"}},
{"field_name": "Nickname", "type": 1, "ui_type": "Text"},
{"field_name": "Gender", "type": 3, "ui_type": "SingleSelect"},
{"field_name": "Phone", "type": 13, "ui_type": "Phone"},
{"field_name": "City", "type": 3, "ui_type": "SingleSelect"},
{"field_name": "OrderID", "type": 1, "ui_type": "Text"},
{"field_name": "PurchaseTimestamp", "type": 5, "ui_type": "DateTime", "property": {"date_formatter": "yyyy/MM/dd HH:mm", "auto_fill": False}},
{"field_name": "OrderAmount", "type": 2, "ui_type": "Number", "property": {"formatter": "0.00"}}
]
}
}
table_id = create_bitable(app_token, table_config, access_token)
if __name__ == '__main__':
main()
Extracting and Processing MySQL Data
Combining data extraction with preprocessing is efficient because Pandas handling depends significantly on the source data format. If the SQL query already transforms data types appropriately, Pandas requires minimal additional processing.
Database Setup
Assume two source tables: a users table and an orders table.
The users table contains: user_id (primary key), mobile, nickname, gender, province, city
The orders table contains: order_id (primary key), user_id (foreign key), product_id, paid_timestamp, amount
Create the tables and insert test data:
-- Database creation
CREATE DATABASE IF NOT EXISTS product_data CHARACTER SET utf8mb4;
USE product_data;
-- Users table
CREATE TABLE users (
user_id BIGINT AUTO_INCREMENT PRIMARY KEY,
mobile VARCHAR(20) NOT NULL,
nickname VARCHAR(50),
gender TINYINT DEFAULT 2 COMMENT '0=female, 1=male, 2=unknown',
province VARCHAR(50),
city VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Orders table
CREATE TABLE orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id VARCHAR(50) NOT NULL,
paid_timestamp BIGINT NOT NULL COMMENT 'Unix timestamp',
amount BIGINT NOT NULL COMMENT 'Amount in cents',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
-- Sample data
INSERT INTO users (mobile, nickname, gender, province, city) VALUES
('13511112222', 'alice', 0, 'Beijing', 'Beijing'),
('13622223333', 'bob', 1, 'Shanghai', 'Shanghai'),
('13733334444', 'charlie', 2, 'Guangdong', 'Guangzhou'),
('13844445555', 'david', 0, 'Guangdong', 'Shenzhen');
INSERT INTO orders (user_id, product_id, paid_timestamp, amount) VALUES
(1, 'PROD001', 1704095836, 99900),
(2, 'PROD001', 1704195836, 99900),
(3, 'PROD001', 1704295836, 99900),
(4, 'PROD002', 1704395836, 89900);
Filter for product_id equal to PROD001 only.
Native SQL Transformation Approach
Transforming data directly in the SQL query is the preferred method as it simplifies downstream processing. The key transformations include converting timestamps to milliseconds and aliasing columns to match bitable field names:
SELECT
u.user_id AS "UserID",
u.nickname AS "Nickname",
CASE u.gender
WHEN 0 THEN 'Female'
WHEN 1 THEN 'Male'
ELSE 'Unknown'
END AS "Gender",
u.mobile AS "Phone",
u.city AS "City",
o.order_id AS "OrderID",
o.paid_timestamp * 1000 AS "PurchaseTimestamp",
o.amount / 100.0 AS "OrderAmount"
FROM product_data.orders o
JOIN product_data.users u ON u.user_id = o.user_id
WHERE o.product_id = 'PROD001';
Load the transformed data using Pandas:
import pandas as pd
from sqlalchemy import create_engine
def load_query_results(query):
connection_string = 'mysql+pymysql://{}:{}@{}:{}/{}?charset=utf8mb4'.format(
"db_username", "db_password", "127.0.0.1", "3306", "product_data"
)
engine = create_engine(connection_string)
df = pd.read_sql(query, engine)
df = df.astype({
"UserID": int,
"Nickname": str,
"Gender": str,
"Phone": str,
"City": str,
"OrderID": int,
"PurchaseTimestamp": 'int64',
"OrderAmount": float
}, errors='ignore')
return df
sql = '''
SELECT
u.user_id AS "UserID",
u.nickname AS "Nickname",
CASE u.gender
WHEN 0 THEN 'Female'
WHEN 1 THEN 'Male'
ELSE 'Unknown'
END AS "Gender",
u.mobile AS "Phone",
u.city AS "City",
o.order_id AS "OrderID",
o.paid_timestamp * 1000 AS "PurchaseTimestamp",
o.amount / 100.0 AS "OrderAmount"
FROM product_data.orders o
JOIN product_data.users u ON u.user_id = o.user_id
WHERE o.product_id = 'PROD001'
'''
df = load_query_results(sql)
print(df)
Note: When running as a standalone script, timestamp fields may encounter issues with negative values when using int type. Using 'int64' resolves this issue.
Pandas-Based Transformation Alternative
If transformation occurs in Pandas instead of SQL:
import pandas as pd
from sqlalchemy import create_engine
def load_raw_data(query):
connection_string = 'mysql+pymysql://{}:{}@{}:{}/{}?charset=utf8mb4'.format(
"db_username", "db_password", "127.0.0.1", "3306", "product_data"
)
engine = create_engine(connection_string)
df = pd.read_sql(query, engine)
df = df.astype({
"user_id": int,
"nickname": str,
"gender": str,
"mobile": str,
"city": str,
"order_id": int,
"paid_time": 'datetime64[ns]',
"amount": float
})
df.columns = ["UserID", "Nickname", "Gender", "Phone", "City", "OrderID", "PurchaseTimestamp", "OrderAmount"]
return df
sql = '''
SELECT
u.user_id,
u.nickname,
CASE u.gender
WHEN 0 THEN 'Female'
WHEN 1 THEN 'Male'
ELSE 'Unknown'
END AS gender,
u.mobile,
u.city,
o.order_id,
FROM_UNIXTIME(o.paid_timestamp) AS paid_time,
o.amount / 100.0 AS amount
FROM product_data.orders o
JOIN product_data.users u ON u.user_id = o.user_id
WHERE o.product_id = 'PROD001'
'''
df = load_raw_data(sql)
Convert datetime columns to epoch milliseconds:
def convert_datetime_to_millis(df):
datetime_columns = df.select_dtypes(include=['datetime64[ns]']).columns
for col in datetime_columns:
df[col] = (df[col] - pd.Timestamp("1970-01-01") - pd.Timedelta(hours=8)) // pd.Timedelta(milliseconds=1)
return df
df = convert_datetime_to_millis(df)
print(df)
Converting to API Request Format
After field names and values are properly formatted, transform the data structure to match the Feishu batch insert API requirements. The API expects nested "fields" and "records" objects.
The API request structure:
{
"records": [
{
"fields": {
"FieldName": "value",
"NumericField": 100,
"SelectField": "Option1",
"DateField": 1674206443000
}
}
]
}
Convert DataFrame to dictionary format:
def transform_to_request_payload(dataframe):
records_dict = dataframe.to_dict(orient='records')
wrapped_fields = [{'fields': record} for record in records_dict]
payload = {'records': wrapped_fields}
return payload
payload = transform_to_request_payload(df)
print(payload)
Inserting Data into Bitable
Insertion requires the app_token, table_id, and prepared request body:
import requests
import json
def insert_batch_records(app_token, table_id, payload, access_token):
endpoint = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
}
request_body = json.dumps(payload).replace(': NaN', ': null')
response = requests.post(endpoint, headers=headers, data=request_body)
result = response.json()
if result.get('code') == 0:
record_count = len(payload['records'])
print(f"Successfully inserted {record_count} records")
else:
raise RuntimeError(f"Insertion failed: {result.get('msg')}")
def main():
app_token = 'APP_TOKEN_VALUE'
table_id = 'TABLE_ID_VALUE'
# df represents the processed data from earlier steps
payload = transform_to_request_payload(df)
insert_batch_records(app_token, table_id, payload, access_token)
if __name__ == '__main__':
main()
Handling Edge Cases
Null Value Processing
When dataframe contains null values, special handling is required:
import pandas as pd
import json
test_data = pd.DataFrame({
'text_field': [None, 'value_b', 'value_c'],
'numeric_field': [1, 2, None]
})
serialized = json.dumps(test_data.to_dict(orient='records'))
print(serialized)
Text fields convert null to the string "null" (acceptable), while numeric fields produce "NaN" (unacceptable to Feishu API). Replace NaN with null:
sanitized_payload = serialized.replace(': NaN', ': null')
Batch Size Limits
The Feishu batch create API accepts maximum 500 records per request. For larger datasets, implement pagination:
def paginate_payload(dataframe, batch_size=500):
records_dict = dataframe.to_dict(orient='records')
payload_batches = []
for start_idx in range(0, len(records_dict), batch_size):
batch = records_dict[start_idx:start_idx + batch_size]
wrapped = [{'fields': record} for record in batch]
payload_batches.append({'records': wrapped})
print(f"Data divided into {len(payload_batches)} batches")
return payload_batches
Complete Integration Code
import requests
import json
import pandas as pd
from sqlalchemy import create_engine
def obtain_tenant_token(app_id, app_secret):
auth_endpoint = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
payload = {"app_id": app_id, "app_secret": app_secret}
response = requests.post(auth_endpoint, json=payload)
token = response.json().get('tenant_access_token')
print(f"Token obtained: {token[:10]}...")
return token
def build_bitable(app_token, config, access_token):
endpoint = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
response = requests.post(endpoint, json=config, headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
})
result = response.json()
if result.get('code') == 0:
table_id = result['data']['table_id']
print(f"Table created: {config['table']['name']} ({table_id})")
return table_id
else:
raise RuntimeError(f"Failed: {result.get('msg')}")
def fetch_data(query, connection_config, schema):
engine = create_engine(connection_config)
df = pd.read_sql(query, engine)
df = df.astype(schema, errors='ignore')
return df
def prepare_payload(dataframe, batch_limit=500):
records_dict = dataframe.to_dict(orient='records')
batches = []
for offset in range(0, len(records_dict), batch_limit):
segment = records_dict[offset:offset + batch_limit]
wrapped = [{'fields': entry} for entry in segment]
batches.append({'records': wrapped})
print(f"Payload prepared: {len(batches)} batch(es)")
return batches
def push_records(app_token, table_id, payload, access_token):
endpoint = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
}
body = json.dumps(payload).replace(': NaN', ': null')
response = requests.post(endpoint, headers=headers, data=body)
result = response.json()
if result.get('code') == 0:
print(f"Inserted {len(payload['records'])} records successfully")
else:
print(result)
raise RuntimeError(f"Insertion error: {result.get('msg')}")
def main():
# Configuration
app_id = 'YOUR_APP_ID'
app_secret = 'YOUR_APP_SECRET'
app_token = 'YOUR_APP_TOKEN'
access_token = obtain_tenant_token(app_id, app_secret)
# Create table
table_config = {
"table": {
"name": "Product Customer List",
"default_view_name": "All Records View",
"fields": [
{"field_name": "UserID", "type": 2, "ui_type": "Number", "property": {"formatter": "0"}},
{"field_name": "Nickname", "type": 1, "ui_type": "Text"},
{"field_name": "Gender", "type": 3, "ui_type": "SingleSelect"},
{"field_name": "Phone", "type": 13, "ui_type": "Phone"},
{"field_name": "City", "type": 3, "ui_type": "SingleSelect"},
{"field_name": "OrderID", "type": 1, "ui_type": "Text"},
{"field_name": "PurchaseTimestamp", "type": 5, "ui_type": "DateTime", "property": {"date_formatter": "yyyy/MM/dd HH:mm", "auto_fill": False}},
{"field_name": "OrderAmount", "type": 2, "ui_type": "Number", "property": {"formatter": "0.00"}}
]
}
}
table_id = build_bitable(app_token, table_config, access_token)
# Query data
query = '''
SELECT
u.user_id AS "UserID",
u.nickname AS "Nickname",
CASE u.gender
WHEN 0 THEN 'Female'
WHEN 1 THEN 'Male'
ELSE 'Unknown'
END AS "Gender",
u.mobile AS "Phone",
u.city AS "City",
o.order_id AS "OrderID",
o.paid_timestamp * 1000 AS "PurchaseTimestamp",
o.amount / 100.0 AS "OrderAmount"
FROM product_data.orders o
JOIN product_data.users u ON u.user_id = o.user_id
WHERE o.product_id = 'PROD001'
'''
connection_string = 'mysql+pymysql://{}:{}@{}:{}/{}?charset=utf8mb4'.format(
"DB_USER", "DB_PASSWORD", "127.0.0.1", "3306", "product_data"
)
field_schema = {
"UserID": int,
"Nickname": str,
"Gender": str,
"Phone": str,
"City": str,
"OrderID": int,
"PurchaseTimestamp": int,
"OrderAmount": float
}
df = fetch_data(query, connection_string, field_schema)
# Prepare and insert
payloads = prepare_payload(df, batch_limit=500)
for batch_payload in payloads:
push_records(app_token, table_id, batch_payload, access_token)
if __name__ == '__main__':
main()
Configure with valid Feishu app credentials and database connection details before execution.